jsfe 1.10.9 → 1.11.1
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/README.md +19 -2
- package/dist/forms/flows.d.ts.map +1 -1
- package/dist/forms/flows.js +46 -10
- package/dist/forms/identity-flows.js +4 -4
- package/dist/helpers/answer.d.ts +5 -0
- package/dist/helpers/answer.d.ts.map +1 -1
- package/dist/helpers/answer.js +21 -2
- package/dist/helpers/contextual-response.d.ts.map +1 -1
- package/dist/helpers/contextual-response.js +3 -1
- package/dist/helpers/index.d.ts +3 -1
- package/dist/helpers/index.d.ts.map +1 -1
- package/dist/helpers/index.js +3 -3
- package/dist/host/index.d.ts +1 -0
- package/dist/host/index.d.ts.map +1 -1
- package/dist/host/index.js +1 -0
- package/dist/host/reply-rank.d.ts +66 -0
- package/dist/host/reply-rank.d.ts.map +1 -0
- package/dist/host/reply-rank.js +126 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +140 -148
- package/dist/runtime-identity.js +1 -1
- package/docs/copilot-reference.md +3 -3
- package/docs/development-release.md +35 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -28,6 +28,7 @@ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
|
28
28
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
29
29
|
**/
|
|
30
30
|
import { buildFlowStage, buildParameterStage, hasStageParameters } from './host/intent-stages.js';
|
|
31
|
+
import { buildReplyRank, REPLY_OPTIONS } from './host/reply-rank.js';
|
|
31
32
|
/*
|
|
32
33
|
// Deploy new version to NPM:
|
|
33
34
|
npm version patch -m "chore(release): %s"
|
|
@@ -137,6 +138,7 @@ const DEFAULT_MESSAGE_REGISTRY = {
|
|
|
137
138
|
flow_switch_general_error: "I encountered an error while switching flows: {{errorMessage}}. Please try again or contact support if the issue persists.",
|
|
138
139
|
// System Commands
|
|
139
140
|
cmd_flow_exited: "Successfully exited {{flowName}}. How can I help you with something else?",
|
|
141
|
+
cmd_request_aborted: "The process has been cancelled at your request - how can I help you?",
|
|
140
142
|
cmd_help_title: "Flow Help - {{flowName}}",
|
|
141
143
|
cmd_help_available_commands: "Available commands while in this flow:",
|
|
142
144
|
cmd_help_cancel: "• \"cancel flow\" - Exit this flow completely",
|
|
@@ -182,6 +184,7 @@ const DEFAULT_MESSAGE_REGISTRY = {
|
|
|
182
184
|
flow_switch_general_error: "Encontré un error al cambiar flujos: {{errorMessage}}. Por favor intente de nuevo o contacte soporte si el problema persiste.",
|
|
183
185
|
// System Commands
|
|
184
186
|
cmd_flow_exited: "Salió exitosamente de {{flowName}}. ¿Cómo puedo ayudarle con algo más?",
|
|
187
|
+
cmd_request_aborted: "El proceso fue cancelado a petición suya - ¿cómo puedo ayudarle?",
|
|
185
188
|
cmd_help_title: "Ayuda del Flujo - {{flowName}}",
|
|
186
189
|
cmd_help_available_commands: "Comandos disponibles en este flujo:",
|
|
187
190
|
cmd_help_cancel: "• \"cancelar flujo\" - Salir completamente de este flujo",
|
|
@@ -448,8 +451,8 @@ export class TransactionManager {
|
|
|
448
451
|
function finalizeFlowTransaction(engine, frame, endedBy) {
|
|
449
452
|
const declared = frame.declaredOutcome;
|
|
450
453
|
if (declared) {
|
|
451
|
-
if (declared.outcome === 'unresolved') {
|
|
452
|
-
TransactionManager.fail(frame.transaction, declared.reason ||
|
|
454
|
+
if (declared.outcome === 'unresolved' || declared.outcome === 'user_aborted') {
|
|
455
|
+
TransactionManager.fail(frame.transaction, declared.reason || declared.outcome);
|
|
453
456
|
}
|
|
454
457
|
else if (declared.outcome === 'pending') {
|
|
455
458
|
TransactionManager.yield(frame.transaction);
|
|
@@ -2169,8 +2172,8 @@ async function detectLanguage(input, engine) {
|
|
|
2169
2172
|
}
|
|
2170
2173
|
}
|
|
2171
2174
|
// === FLOW EXECUTION ENGINE WITH ENHANCED ERROR HANDLING ===
|
|
2172
|
-
async function isFlowActivated(input, engine, userId = 'anonymous'
|
|
2173
|
-
const result =
|
|
2175
|
+
async function isFlowActivated(input, engine, userId = 'anonymous') {
|
|
2176
|
+
const result = await detectFlowWithParameters(input, engine);
|
|
2174
2177
|
if (result && result.flow) {
|
|
2175
2178
|
const flow = result.flow;
|
|
2176
2179
|
const parameters = result.parameters;
|
|
@@ -2495,8 +2498,8 @@ async function playFlowFrame(engine) {
|
|
|
2495
2498
|
}
|
|
2496
2499
|
// DISPATCH ended every flow; processActivity routes this turn's input (see handleDispatchStep)
|
|
2497
2500
|
if (step.type === 'DISPATCH' && step.onResponse === 'continue') {
|
|
2498
|
-
//
|
|
2499
|
-
if (engine.pendingDispatchFrom !== null)
|
|
2501
|
+
// An answer retains the stack; abort (a RETURN) and a new request (intent detection) end every flow.
|
|
2502
|
+
if (engine.pendingDispatchFrom !== null || getCurrentStackLength(engine) === 0)
|
|
2500
2503
|
return result;
|
|
2501
2504
|
continue;
|
|
2502
2505
|
}
|
|
@@ -2882,7 +2885,7 @@ function enforceParameterTypes(flow, parameters) {
|
|
|
2882
2885
|
return result;
|
|
2883
2886
|
}
|
|
2884
2887
|
/** The structured form of the intent-detection call, passed to aiCallback as its 4th argument. */
|
|
2885
|
-
function buildDetectFlowRequest(input, lastChatTurn, flows
|
|
2888
|
+
function buildDetectFlowRequest(input, lastChatTurn, flows) {
|
|
2886
2889
|
const conversation = [];
|
|
2887
2890
|
for (const entry of [lastChatTurn?.user, lastChatTurn?.assistant]) {
|
|
2888
2891
|
if (entry && (entry.role === 'user' || entry.role === 'assistant')) {
|
|
@@ -2892,8 +2895,6 @@ function buildDetectFlowRequest(input, lastChatTurn, flows, responseContext) {
|
|
|
2892
2895
|
// absent, or a role the conversation cannot carry
|
|
2893
2896
|
}
|
|
2894
2897
|
}
|
|
2895
|
-
if (responseContext !== undefined)
|
|
2896
|
-
conversation.push({ role: 'assistant', content: responseContext });
|
|
2897
2898
|
return {
|
|
2898
2899
|
task: 'detect_flow',
|
|
2899
2900
|
input: String(sanitizeInput(input)),
|
|
@@ -2919,8 +2920,8 @@ function buildDetectFlowRequest(input, lastChatTurn, flows, responseContext) {
|
|
|
2919
2920
|
* ({ flowName } / { parameters }). A stage-1 failure reaches the host as before; a stage-2 failure starts the flow
|
|
2920
2921
|
* without its parameters (it asks for them) rather than losing the turn.
|
|
2921
2922
|
*/
|
|
2922
|
-
async function detectFlowInTwoStages(input, engine, flows
|
|
2923
|
-
const { input: cleanInput, conversation } = buildDetectFlowRequest(input, engine.lastChatTurn, []
|
|
2923
|
+
async function detectFlowInTwoStages(input, engine, flows) {
|
|
2924
|
+
const { input: cleanInput, conversation } = buildDetectFlowRequest(input, engine.lastChatTurn, []);
|
|
2924
2925
|
const stage1 = buildFlowStage(cleanInput, conversation, flows.map(f => ({ id: f.id, name: f.name, description: String(f.description ?? '') })));
|
|
2925
2926
|
const reply1 = parseStageReply(await fetchAiResponse(stage1.llm.system, stage1.llm.user, engine.aiCallback, engine.aiTimeOut, stageSchema('detect_flow', stage1.llm.schema), stage1));
|
|
2926
2927
|
const said = String(reply1.flowName ?? 'None');
|
|
@@ -2944,13 +2945,50 @@ async function detectFlowInTwoStages(input, engine, flows, responseContext) {
|
|
|
2944
2945
|
return { flow, parameters: enforceParameterEnums(flow, enforceParameterTypes(flow, ownParametersOnly(flow, given))) };
|
|
2945
2946
|
}
|
|
2946
2947
|
catch (error) {
|
|
2947
|
-
if (flow.id === '__jsfe_continue_current__' && responseContext !== undefined)
|
|
2948
|
-
throw error;
|
|
2949
2948
|
logger.warn(`detectFlowWithParameters: stage 2 failed for "${flow.name}" (${error.message}) - the flow starts without its parameters`);
|
|
2950
2949
|
return { flow, parameters: {} };
|
|
2951
2950
|
}
|
|
2952
2951
|
}
|
|
2953
2952
|
}
|
|
2953
|
+
/**
|
|
2954
|
+
* The reply ranker (jsfe/host reply-rank): what the latest message does with the question a contextual DISPATCH
|
|
2955
|
+
* asked — `answers`, `abort` or `new_request`. ONE aiCallback call whose 4th argument carries both executors'
|
|
2956
|
+
* instructions; the host answers with System One and covers it with a text model (its aiCallbackRank). A reply that
|
|
2957
|
+
* is not an offered option throws, and the pending step stays for the caller's error policy. `confident` is false only
|
|
2958
|
+
* when the host says `answers` is the default because no option cleared its cut (`fallback: true`); an answer
|
|
2959
|
+
* without that flag (a text model's pick, or a host that does not send it) is a confident one.
|
|
2960
|
+
*/
|
|
2961
|
+
async function rankReply(input, engine, question) {
|
|
2962
|
+
const { input: cleanInput, conversation } = buildDetectFlowRequest(input, engine.lastChatTurn, []);
|
|
2963
|
+
const request = buildReplyRank(cleanInput, conversation, question);
|
|
2964
|
+
const reply = parseStageReply(await fetchAiResponse(request.llm.system, request.llm.user, engine.aiCallback, engine.aiTimeOut, stageSchema('rank_reply', request.llm.schema), request));
|
|
2965
|
+
const option = String(reply.option ?? '');
|
|
2966
|
+
if (!REPLY_OPTIONS.includes(option))
|
|
2967
|
+
throw new Error(`rank_reply: "${option.slice(0, 40)}" is not an offered option`);
|
|
2968
|
+
const confident = reply.fallback !== true;
|
|
2969
|
+
logger.info(`rank_reply: ${option}${confident ? '' : ' (default: no option cleared the cut)'} for ${JSON.stringify(cleanInput)}`);
|
|
2970
|
+
return { option, confident };
|
|
2971
|
+
}
|
|
2972
|
+
/**
|
|
2973
|
+
* The field a confidently ranked answer carries as said when extraction finds nothing: the ONE parameter of a
|
|
2974
|
+
* declaration that marks it `verbatim: true` (an open answer whose words are the value, such as a reason). Opt-in:
|
|
2975
|
+
* a field that names a thing to look up (a product, a city) needs a real extraction, never the whole reply.
|
|
2976
|
+
*/
|
|
2977
|
+
function verbatimReplyField(parameters) {
|
|
2978
|
+
return parameters.length === 1 && parameters[0].verbatim === true ? parameters[0].name : null;
|
|
2979
|
+
}
|
|
2980
|
+
/**
|
|
2981
|
+
* A typed contextual response: the reply answers the question, so its declared fields are extracted with the
|
|
2982
|
+
* intent stage-2 request (the question is the assistant's last turn). Missing fields stay missing; a failure throws.
|
|
2983
|
+
*/
|
|
2984
|
+
async function extractReplyParameters(input, engine, question, parameters) {
|
|
2985
|
+
const { input: cleanInput, conversation } = buildDetectFlowRequest(input, engine.lastChatTurn, []);
|
|
2986
|
+
const reply = { id: '__jsfe_reply__', name: 'CurrentQuestion', version: '1.0.0', description: 'The reply to the current question', steps: [], parameters: parameters.map(({ verbatim, ...parameter }) => parameter) };
|
|
2987
|
+
const stage2 = buildParameterStage(cleanInput, conversation.concat([{ role: 'assistant', content: question }]), reply);
|
|
2988
|
+
const answer = parseStageReply(await fetchAiResponse(stage2.llm.system, stage2.llm.user, engine.aiCallback, engine.aiTimeOut, stageSchema('extract_parameters', stage2.llm.schema), stage2));
|
|
2989
|
+
const given = answer.parameters && typeof answer.parameters === 'object' ? answer.parameters : {};
|
|
2990
|
+
return structuredClone(enforceParameterEnums(reply, enforceParameterTypes(reply, ownParametersOnly(reply, given))));
|
|
2991
|
+
}
|
|
2954
2992
|
/** The OpenAI-compatible response_format wrapper a host already reads from the 3rd argument. */
|
|
2955
2993
|
function stageSchema(name, schema) {
|
|
2956
2994
|
return JSON.stringify({ type: 'json_schema', json_schema: { name, strict: true, schema } });
|
|
@@ -2966,7 +3004,7 @@ export async function getFlowForInput(input, engine) {
|
|
|
2966
3004
|
const result = await detectFlowWithParameters(input, engine);
|
|
2967
3005
|
return result ? result.flow : null;
|
|
2968
3006
|
}
|
|
2969
|
-
export async function detectFlowWithParameters(input, engine
|
|
3007
|
+
export async function detectFlowWithParameters(input, engine) {
|
|
2970
3008
|
// CRITICAL: This function requires AI access via engine.aiCallback
|
|
2971
3009
|
// Without AI, the engine cannot detect user intent and activate flows
|
|
2972
3010
|
// Sessions would never be activated as no workflows could be triggered
|
|
@@ -2984,7 +3022,7 @@ export async function detectFlowWithParameters(input, engine, continuation, resp
|
|
|
2984
3022
|
}
|
|
2985
3023
|
// Filter to only primary flows for AI intent detection
|
|
2986
3024
|
const primaryFlows = flowsMenu.filter(flow => flow.primary === true);
|
|
2987
|
-
const flowsForIntentDetection =
|
|
3025
|
+
const flowsForIntentDetection = primaryFlows.length > 0 ? primaryFlows : flowsMenu;
|
|
2988
3026
|
logger.debug(`Using ${flowsForIntentDetection.length} flows for intent detection (${primaryFlows.length} primary flows available)`);
|
|
2989
3027
|
// A host start (ContextEntry.start) answers detection for this turn: that flow, with its arguments.
|
|
2990
3028
|
if (engine.hostStart) {
|
|
@@ -3014,7 +3052,7 @@ export async function detectFlowWithParameters(input, engine, continuation, resp
|
|
|
3014
3052
|
return null;
|
|
3015
3053
|
}
|
|
3016
3054
|
if (engine.intentDetection === 'two-stage') {
|
|
3017
|
-
return await detectFlowInTwoStages(input, engine, flowsForIntentDetection
|
|
3055
|
+
return await detectFlowInTwoStages(input, engine, flowsForIntentDetection);
|
|
3018
3056
|
}
|
|
3019
3057
|
else {
|
|
3020
3058
|
// the original single call, below
|
|
@@ -3046,12 +3084,10 @@ export async function detectFlowWithParameters(input, engine, continuation, resp
|
|
|
3046
3084
|
</chat-history>\n\n`;
|
|
3047
3085
|
}
|
|
3048
3086
|
// Let AI errors (timeout, communication failures) propagate to the host
|
|
3049
|
-
|
|
3050
|
-
context += `<current-response-context>\n${responseContext}\n</current-response-context>\n`;
|
|
3051
|
-
const request = buildDetectFlowRequest(input, engine.lastChatTurn, flowsForIntentDetection, responseContext);
|
|
3087
|
+
const request = buildDetectFlowRequest(input, engine.lastChatTurn, flowsForIntentDetection);
|
|
3052
3088
|
const aiResponse = await fetchAiTask(task, rules, context, input, flowsForIntentDetection, jsonSchema, engine.aiCallback, engine.aiTimeOut, request);
|
|
3053
3089
|
if (aiResponse && aiResponse.flowName && aiResponse.flowName !== 'None' && aiResponse.flowName !== 'null') {
|
|
3054
|
-
const flow = flowsMenu.
|
|
3090
|
+
const flow = flowsMenu.find(flow => flow.name.toLowerCase() === aiResponse.flowName.toLowerCase() || flow.id === aiResponse.flowName);
|
|
3055
3091
|
if (flow) {
|
|
3056
3092
|
// Defensive: strip empty-string keys from AI-generated parameters.
|
|
3057
3093
|
// AI occasionally emits { "": value } which would cascade into flow
|
|
@@ -4123,6 +4159,21 @@ async function handleDispatchStep(currentFlowFrame, engine) {
|
|
|
4123
4159
|
}
|
|
4124
4160
|
return terminateForDispatch(currentFlowFrame, engine);
|
|
4125
4161
|
}
|
|
4162
|
+
/** A withdrawn request ends as a RETURN does: the DISPATCH step is consumed and every flow finalizes and ends. */
|
|
4163
|
+
function terminateForAbort(currentFlowFrame, engine) {
|
|
4164
|
+
const step = currentFlowFrame.flowStepsStack.pop();
|
|
4165
|
+
// The caller chose to stop: `user_aborted`, whatever the step declared for an unanswered question; its reason says where.
|
|
4166
|
+
const declared = step.outcome !== undefined ? resolveDeclaredOutcome(step, currentFlowFrame, engine) : currentFlowFrame.declaredOutcome;
|
|
4167
|
+
currentFlowFrame.declaredOutcome = { outcome: 'user_aborted', reason: declared?.reason || 'request_withdrawn' };
|
|
4168
|
+
while (engine.flowStacks.length > 0) {
|
|
4169
|
+
const poppedStack = engine.flowStacks.pop();
|
|
4170
|
+
for (const flow of poppedStack || []) {
|
|
4171
|
+
finalizeFlowTransaction(engine, flow, 'return');
|
|
4172
|
+
auditLogger.logFlowExit(flow.flowName, currentFlowFrame.userId, flow.transaction.id, 'request_withdrawn');
|
|
4173
|
+
}
|
|
4174
|
+
}
|
|
4175
|
+
initializeFlowStacks(engine);
|
|
4176
|
+
}
|
|
4126
4177
|
function terminateForDispatch(currentFlowFrame, engine) {
|
|
4127
4178
|
// DISPATCH = forget every flow and treat THIS turn's input as if no flow had been active:
|
|
4128
4179
|
// processActivity runs intent detection on the input the turn started with (never a flow
|
|
@@ -5685,22 +5736,11 @@ async function handleIntentInterruption(input, engine, userId) {
|
|
|
5685
5736
|
currentFlowFrame.lastSayMessage ||
|
|
5686
5737
|
currentFlowFrame.flowStepsStack.length > 0;
|
|
5687
5738
|
logger.debug(`Interrupt intent analysis for: ${input} pendingVariable: ${currentFlowFrame.pendingVariable}, lastSayMessage: ${!!currentFlowFrame.lastSayMessage}, stepsLeft: ${currentFlowFrame.flowStepsStack.length}`);
|
|
5688
|
-
//
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
}
|
|
5694
|
-
else {
|
|
5695
|
-
// Fallback to exact match for non-AI environments
|
|
5696
|
-
const flowsMenu = engine.flowsMenu || [];
|
|
5697
|
-
const matchingFlow = flowsMenu.find(flow => flow.id.toLowerCase() === input.toLowerCase() || flow.name.toLowerCase() === input.toLowerCase());
|
|
5698
|
-
intentAnalysis = {
|
|
5699
|
-
isStrongIntent: !!matchingFlow,
|
|
5700
|
-
targetFlow: matchingFlow ? matchingFlow.id : null
|
|
5701
|
-
};
|
|
5702
|
-
logger.info(`Intent analysis (no AI):`, intentAnalysis);
|
|
5703
|
-
}
|
|
5739
|
+
// The same intent detection as a new request (two-stage when the host configured it): a detected flow interrupts,
|
|
5740
|
+
// started with the arguments detection extracted.
|
|
5741
|
+
const detected = await detectFlowWithParameters(input, engine);
|
|
5742
|
+
const intentAnalysis = { isStrongIntent: !!detected, targetFlow: detected ? detected.flow.name : null, parameters: detected?.parameters || {}, originalInput: input };
|
|
5743
|
+
logger.debug(`Interruption detection: ${JSON.stringify({ targetFlow: intentAnalysis.targetFlow, parameters: intentAnalysis.parameters })}`);
|
|
5704
5744
|
if (intentAnalysis.isStrongIntent && intentAnalysis.targetFlow) {
|
|
5705
5745
|
// Check if the target flow is different from current flow
|
|
5706
5746
|
if (intentAnalysis.targetFlow !== currentFlowName) {
|
|
@@ -5713,76 +5753,6 @@ async function handleIntentInterruption(input, engine, userId) {
|
|
|
5713
5753
|
return null; // No strong intent interruption detected
|
|
5714
5754
|
}
|
|
5715
5755
|
// Analyze intent strength using AI
|
|
5716
|
-
async function analyzeIntentStrength(input, engine) {
|
|
5717
|
-
const currentFlowFrame = getCurrentFlowFrame(engine);
|
|
5718
|
-
const currentFlowName = currentFlowFrame.flowName;
|
|
5719
|
-
const flowsMenu = engine.flowsMenu || [];
|
|
5720
|
-
// Use the enhanced fetchAiTask worker with JSON schema
|
|
5721
|
-
const task = "Analyze the user input within the current flow context to determine if it represents a STRONG intent to switch to a completely different workflow, or if it's a response to the current flow.";
|
|
5722
|
-
const rules = `Critical Analysis Framework:
|
|
5723
|
-
1. Examine the flow history to understand what question or prompt the current flow is expecting
|
|
5724
|
-
2. Determine if the user input is:
|
|
5725
|
-
- A STRONG intent for a new workflow (clear, specific action request)
|
|
5726
|
-
- A response to the current flow's prompt (continue current flow)
|
|
5727
|
-
- A clarification or question about the current flow (continue current flow)
|
|
5728
|
-
|
|
5729
|
-
STRONG intent indicators (switch to new flow):
|
|
5730
|
-
- Explicit action requests: "I want to make a payment", "Check my account balance"
|
|
5731
|
-
- Complete topic changes: "What's the weather?" when in a payment flow
|
|
5732
|
-
- New service requests: "I need to transfer money" when in account verification
|
|
5733
|
-
|
|
5734
|
-
WEAK intent indicators (continue current flow):
|
|
5735
|
-
- Direct responses to prompts: "1", "2", "Yes", "No", specific values
|
|
5736
|
-
- Follow-up questions: "What does that mean?", "Can you clarify?"
|
|
5737
|
-
- Partial information: Single words, numbers, or brief phrases that answer the current question
|
|
5738
|
-
- Corrections: "Actually, it's 123-456-7890" when asked for a phone number
|
|
5739
|
-
|
|
5740
|
-
Consider the flow context carefully - if the flow just asked a specific question, treat the input as a response unless it's clearly unrelated.`;
|
|
5741
|
-
// Use flow context when analyzing intent (we're already in a flow)
|
|
5742
|
-
let context = `CURRENT SITUATION: User is in "${currentFlowName}" flow. Analyze if their input is a response to this flow or a request for a different workflow.
|
|
5743
|
-
|
|
5744
|
-
IMPORTANT: Be very conservative about interrupting flows - only classify as STRONG intent if the input is clearly unrelated to what the current flow is asking for.`;
|
|
5745
|
-
if (currentFlowFrame.contextStack && currentFlowFrame.contextStack.length > 0) {
|
|
5746
|
-
const flowContext = flattenContextStack(currentFlowFrame.contextStack, true);
|
|
5747
|
-
context = `<flow-history>
|
|
5748
|
-
${flowContext}
|
|
5749
|
-
</flow-history>
|
|
5750
|
-
|
|
5751
|
-
${context}`;
|
|
5752
|
-
}
|
|
5753
|
-
const jsonSchema = JSON.stringify({
|
|
5754
|
-
type: "json_schema",
|
|
5755
|
-
json_schema: {
|
|
5756
|
-
name: "intent_analysis",
|
|
5757
|
-
strict: true,
|
|
5758
|
-
schema: {
|
|
5759
|
-
type: "object",
|
|
5760
|
-
properties: {
|
|
5761
|
-
isStrongIntent: { type: "boolean" },
|
|
5762
|
-
targetFlow: { type: ["string", "null"] },
|
|
5763
|
-
confidence: { type: "number" },
|
|
5764
|
-
reasoning: { type: "string" }
|
|
5765
|
-
},
|
|
5766
|
-
required: ["isStrongIntent", "targetFlow", "confidence", "reasoning"],
|
|
5767
|
-
additionalProperties: false
|
|
5768
|
-
}
|
|
5769
|
-
}
|
|
5770
|
-
});
|
|
5771
|
-
try {
|
|
5772
|
-
const analysis = await fetchAiTask(task, rules, context, input, flowsMenu, jsonSchema, engine.aiCallback, engine.aiTimeOut);
|
|
5773
|
-
// Ensure analysis is an object and add originalInput
|
|
5774
|
-
if (typeof analysis === 'object' && analysis !== null) {
|
|
5775
|
-
analysis.originalInput = input; // Preserve the original input
|
|
5776
|
-
}
|
|
5777
|
-
logger.info(`Intent analysis:`, analysis);
|
|
5778
|
-
return analysis;
|
|
5779
|
-
}
|
|
5780
|
-
catch (error) {
|
|
5781
|
-
logger.warn(`Intent analysis error:`, error.message);
|
|
5782
|
-
logger.info(`Stack trace:`, error.stack);
|
|
5783
|
-
return { isStrongIntent: false, targetFlow: null, confidence: 0, reasoning: `Error: ${error.message}`, originalInput: input };
|
|
5784
|
-
}
|
|
5785
|
-
}
|
|
5786
5756
|
// Handle confirmed switch from pending interruption
|
|
5787
5757
|
async function handlePendingInterruptionSwitch(engine, userId) {
|
|
5788
5758
|
const currentFlowFrame = getCurrentFlowFrame(engine);
|
|
@@ -5875,7 +5845,7 @@ async function handleRegularFlowInterruption(intentAnalysis, engine, userId) {
|
|
|
5875
5845
|
flowStepsStack: cloneFlowValue(targetFlowDefinition.steps).reverse(),
|
|
5876
5846
|
contextStack: [], // Start with empty context - don't use interruption input
|
|
5877
5847
|
inputStack: [], // Start with empty input stack - new flow will get proper input next
|
|
5878
|
-
variables: getInitialVariables(engine, targetFlowDefinition), //
|
|
5848
|
+
variables: { ...getInitialVariables(engine, targetFlowDefinition), ...(intentAnalysis.parameters || {}) }, // fresh variables + the arguments detection extracted
|
|
5879
5849
|
transaction: newTransaction,
|
|
5880
5850
|
userId: userId,
|
|
5881
5851
|
startTime: Date.now()
|
|
@@ -5941,26 +5911,16 @@ async function processActivity(input, userId, engine) {
|
|
|
5941
5911
|
logger.debug(`Current stack length: ${getCurrentStackLength(engine)}`);
|
|
5942
5912
|
// A DISPATCH is consumed within the call that raised it; never carry one into another turn.
|
|
5943
5913
|
engine.pendingDispatchFrom = null;
|
|
5944
|
-
// One routing decision for the original turn, shared by guarded and ordinary dispatch.
|
|
5945
|
-
let detected;
|
|
5946
5914
|
let activatedThisTurn = false;
|
|
5947
|
-
|
|
5915
|
+
// The reply ranker's verdict this turn. Only a new request is routed: after `answers` a later ordinary DISPATCH
|
|
5916
|
+
// (e.g. an answer with nothing usable in it) hands the turn to the host, so an answer never restarts its own flow.
|
|
5917
|
+
let ranked = null;
|
|
5918
|
+
// `abort` is a RETURN: every flow ends and this is the turn's reply.
|
|
5919
|
+
let abortReply = null;
|
|
5948
5920
|
let guardedRoutingFailed = false;
|
|
5949
|
-
const route = async (continuation, responseContext) => {
|
|
5950
|
-
if (detected === undefined) {
|
|
5951
|
-
detected = await detectFlowWithParameters(String(sanitizedInput), engine, continuation, responseContext);
|
|
5952
|
-
detectedResponseContext = responseContext;
|
|
5953
|
-
}
|
|
5954
|
-
else if (continuation?.parameters?.length && (responseContext !== detectedResponseContext || JSON.stringify(continuation.parameters) !== JSON.stringify(detected?.flow.parameters))) {
|
|
5955
|
-
throw new Error('A typed contextual response cannot reuse an incompatible routing decision this turn');
|
|
5956
|
-
}
|
|
5957
|
-
return detected;
|
|
5958
|
-
};
|
|
5959
5921
|
engine.dispatchIfMatched = async (frame, step) => {
|
|
5960
5922
|
if (activatedThisTurn && step.responseParameters === undefined && step.responseVariable === undefined)
|
|
5961
5923
|
return false;
|
|
5962
|
-
if (engine.flowsMenu.some(flow => flow.name.toLowerCase() === 'continuecurrent' || flow.id.toLowerCase() === '__jsfe_continue_current__'))
|
|
5963
|
-
throw new Error('ContinueCurrent is reserved for contextual dispatch');
|
|
5964
5924
|
let declaration;
|
|
5965
5925
|
try {
|
|
5966
5926
|
declaration = validateContextualResponseStep(step, engine.flowsMenu.find(flow => flow.id === frame.flowId));
|
|
@@ -5971,7 +5931,7 @@ async function processActivity(input, userId, engine) {
|
|
|
5971
5931
|
guardedRoutingFailed = true;
|
|
5972
5932
|
throw error;
|
|
5973
5933
|
}
|
|
5974
|
-
const
|
|
5934
|
+
const question = interpolateMessage(step.responseContext, frame.contextStack, frame.variables, engine).trim();
|
|
5975
5935
|
if (declaration) {
|
|
5976
5936
|
declaration.parameters = declaration.parameters.map((parameter) => ({ ...parameter, description: interpolateMessage(parameter.description, frame.contextStack, frame.variables, engine).trim() }));
|
|
5977
5937
|
if (declaration.parameters.some((parameter) => !parameter.description)) {
|
|
@@ -5979,36 +5939,65 @@ async function processActivity(input, userId, engine) {
|
|
|
5979
5939
|
throw new Error('Contextual response parameter description resolved empty');
|
|
5980
5940
|
}
|
|
5981
5941
|
}
|
|
5982
|
-
|
|
5983
|
-
let
|
|
5942
|
+
// A model error leaves this live continuation pending for the caller's error policy.
|
|
5943
|
+
let option;
|
|
5944
|
+
let confident;
|
|
5984
5945
|
try {
|
|
5985
|
-
|
|
5946
|
+
({ option, confident } = await rankReply(String(sanitizedInput), engine, question));
|
|
5986
5947
|
}
|
|
5987
5948
|
catch (error) {
|
|
5988
5949
|
guardedRoutingFailed = true;
|
|
5989
5950
|
throw error;
|
|
5990
5951
|
}
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
|
|
5998
|
-
|
|
5999
|
-
|
|
5952
|
+
ranked = option;
|
|
5953
|
+
if (option === 'answers') {
|
|
5954
|
+
if (declaration) {
|
|
5955
|
+
// A confident answer is never lost: when extraction finds nothing (or fails) in a `verbatim` field, the
|
|
5956
|
+
// field is the reply as said. A default `answers` keeps extraction as the judge, so an empty field
|
|
5957
|
+
// stays empty and the flow asks again.
|
|
5958
|
+
const verbatim = confident ? verbatimReplyField(declaration.parameters) : null;
|
|
5959
|
+
let extracted;
|
|
5960
|
+
try {
|
|
5961
|
+
extracted = await extractReplyParameters(String(sanitizedInput), engine, question, declaration.parameters);
|
|
5962
|
+
}
|
|
5963
|
+
catch (error) {
|
|
5964
|
+
if (verbatim) {
|
|
5965
|
+
logger.warn(`Contextual DISPATCH in '${frame.flowName}': extraction failed (${error?.message}); the confident answer is kept as said`);
|
|
5966
|
+
extracted = {};
|
|
5967
|
+
}
|
|
5968
|
+
else {
|
|
5969
|
+
guardedRoutingFailed = true;
|
|
5970
|
+
throw error;
|
|
5971
|
+
}
|
|
5972
|
+
}
|
|
5973
|
+
if (verbatim && !(typeof extracted[verbatim] === 'string' && extracted[verbatim].trim())) {
|
|
5974
|
+
logger.info(`Contextual DISPATCH in '${frame.flowName}': nothing extracted from a confident answer; '${verbatim}' is the reply as said`);
|
|
5975
|
+
extracted = { ...extracted, [verbatim]: String(sanitizedInput) };
|
|
5976
|
+
}
|
|
5977
|
+
else {
|
|
5978
|
+
// extraction carried the field, or the answer was not confident or not open
|
|
5979
|
+
}
|
|
5980
|
+
frame.variables[declaration.variable] = extracted;
|
|
5981
|
+
}
|
|
5982
|
+
else {
|
|
5983
|
+
// no typed response: the flow reads the reply itself
|
|
5984
|
+
}
|
|
5985
|
+
logger.info(`Contextual DISPATCH in '${frame.flowName}': the reply answers the current question; the flow continues`);
|
|
6000
5986
|
return false;
|
|
6001
5987
|
}
|
|
6002
|
-
|
|
6003
|
-
|
|
6004
|
-
|
|
6005
|
-
|
|
6006
|
-
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
6010
|
-
|
|
6011
|
-
|
|
5988
|
+
else if (option === 'abort') {
|
|
5989
|
+
// No recovery stamp: the caller chose to stop, so the host has nothing to resume or post-mortem.
|
|
5990
|
+
abortReply = getSystemMessage(engine, 'cmd_request_aborted');
|
|
5991
|
+
terminateForAbort(frame, engine);
|
|
5992
|
+
logger.info(`Contextual DISPATCH in '${frame.flowName}': the caller withdrew the request; every flow ended`);
|
|
5993
|
+
return true;
|
|
5994
|
+
}
|
|
5995
|
+
else {
|
|
5996
|
+
stampStepRecovery(step, frame, engine);
|
|
5997
|
+
terminateForDispatch(frame, engine);
|
|
5998
|
+
logger.info(`Contextual DISPATCH in '${frame.flowName}': a new request; this turn's input goes to intent detection`);
|
|
5999
|
+
return true;
|
|
6000
|
+
}
|
|
6012
6001
|
};
|
|
6013
6002
|
// Check if we're already in a flow (using new stack-of-stacks)
|
|
6014
6003
|
if (getCurrentStackLength(engine) > 0) {
|
|
@@ -6056,7 +6045,11 @@ async function processActivity(input, userId, engine) {
|
|
|
6056
6045
|
logger.info(`Flow context updated. inputStack: ${JSON.stringify(currentFlowFrame.inputStack)}, contextStack length: ${currentFlowFrame.contextStack.length}`);
|
|
6057
6046
|
try {
|
|
6058
6047
|
const response = await playFlowFrame(engine);
|
|
6059
|
-
if (
|
|
6048
|
+
if (abortReply !== null) {
|
|
6049
|
+
logger.info(`Flow response (request withdrawn): ${abortReply}`);
|
|
6050
|
+
return abortReply;
|
|
6051
|
+
}
|
|
6052
|
+
else if (engine.pendingDispatchFrom === null) {
|
|
6060
6053
|
logger.info(`Flow response: ${response}`);
|
|
6061
6054
|
return response;
|
|
6062
6055
|
}
|
|
@@ -6083,8 +6076,7 @@ async function processActivity(input, userId, engine) {
|
|
|
6083
6076
|
const dispatchedFrom = engine.pendingDispatchFrom;
|
|
6084
6077
|
engine.pendingDispatchFrom = null;
|
|
6085
6078
|
try {
|
|
6086
|
-
const
|
|
6087
|
-
const activatedFlow = activatedThisTurn || routed?.flow.id === '__jsfe_continue_current__' ? null : await isFlowActivated(String(sanitizedInput), engine, userId, routed);
|
|
6079
|
+
const activatedFlow = activatedThisTurn || (ranked !== null && ranked !== 'new_request') ? null : await isFlowActivated(String(sanitizedInput), engine, userId);
|
|
6088
6080
|
if (activatedFlow)
|
|
6089
6081
|
activatedThisTurn = true;
|
|
6090
6082
|
if (dispatchedFrom !== null) {
|
package/dist/runtime-identity.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const JSFE_RUNTIME_FINGERPRINT = "sha256:
|
|
1
|
+
export const JSFE_RUNTIME_FINGERPRINT = "sha256:25d1acd637baced87978aa81eb3c47112eb8d82d1021dfb3fedc68aba0b8c988";
|
|
@@ -247,11 +247,11 @@ The loop skips accepted data and asks the first ready missing required field. Co
|
|
|
247
247
|
|
|
248
248
|
### Retry, yield and recovery
|
|
249
249
|
|
|
250
|
-
`max_misses` defaults to two consecutive unreadable answers: clarify the first with explicit retry text or a localized “Sorry, I didn't catch that.” prefix; yield the second utterance to DISPATCH while preserving the draft.
|
|
250
|
+
`max_misses` defaults to two consecutive unreadable answers: clarify the first with explicit retry text or a localized “Sorry, I didn't catch that.” prefix; yield the second utterance to DISPATCH while preserving the draft. An unreadable answer of three or more words (any words at an amount) goes to the reply ranker instead: `answers` takes the offered choice it picks, else asks again (counted against `max_attempts`); only `abort` or `new_request` leaves the form. Validator failures use a separate `max_attempts` budget. An out-of-order accepted answer is not a miss. On exhaustion the nearest declared `on_exhausted` receives `{form,input,failed_form,failed_input,reason:"attempts_exhausted"}` and must deliberately recover that branch; it does not receive consent.
|
|
251
251
|
|
|
252
252
|
`must_complete:true` on a form (inherited by every field of it and of the forms nested in it) or on one field (`false` on a field opts out): an unreadable answer is never yielded to DISPATCH, however long, and neither is a confirmation miss or an unresolved ambiguity — the question is asked again, acknowledged. Use it where leaving would strand an effect in progress (a payment's card, its billing-ZIP correction). Exits stay explicit: the form's stop words, `max_attempts` with `on_exhausted`, and whatever the host intercepts before the engine. The form runaway guard counts passes without a caller answer only.
|
|
253
253
|
|
|
254
|
-
Use `response:{description}` for open descriptions that need
|
|
254
|
+
Use `response:{description}` for open descriptions that need a model's interpretation. The prepared question and field description become the contextual DISPATCH question, and the reply ranker decides what the reply does: an answer is extracted and still passes deterministic validation (missing usable data yields with progress saved); a withdrawal leaves for the host; a new request routes through intent detection. No separate field classifier/model role is needed. Do not attempt exhaustive conversational inference with keyword lists.
|
|
255
255
|
|
|
256
256
|
Ordinary DISPATCH is the default uncertainty escape. An explicit contextual DISPATCH may additionally declare the following step fragment (its surrounding flow must declare the response object and supply its own input/recovery steps):
|
|
257
257
|
|
|
@@ -264,7 +264,7 @@ Ordinary DISPATCH is the default uncertainty escape. An explicit contextual DISP
|
|
|
264
264
|
}
|
|
265
265
|
```
|
|
266
266
|
|
|
267
|
-
Declare `response` as a local object variable.
|
|
267
|
+
Declare `response` as a local object variable. A single open string parameter whose words are the value (a reason, a description) may add `"verbatim":true`: a confident `answers` then keeps the reply as said when extraction finds nothing. Never mark a field that names something to look up (a product, a city). The reply ranker answers `answers`, `abort` or `new_request` (nothing clear is `answers`). `answers` retains the live continuation and runs the following recovery steps; an optional typed response writes a cloned normalized object before continuing. `new_request` routes the input through intent detection; `abort` ends every flow as a RETURN with the reply `cmd_request_aborted` and outcome `user_aborted` (no recovery stamp). Neither writes that response object. Typed contextual v1 permits primitive string/boolean/number/integer fields, not object/array/jsonSchema. Missing fields remain missing. Errors preserve the pending step and prior object. Context is supplied to that decision without overwriting saved conversation history. This is existing configured intent/extraction, not authorization and not a snapshot restore.
|
|
268
268
|
|
|
269
269
|
## 7. Providers
|
|
270
270
|
|
|
@@ -207,3 +207,38 @@ The 1.7.10 publication workflow was stopped before publication after the host-co
|
|
|
207
207
|
Correct four OTP decision expressions to reference the renamed `otp_phrases` catalog. The incomplete rename in 1.7.11 prevented resend, lost-phone, later and some invalid-code recovery branches from executing correctly. Consent, authentication, action persistence and tool permissions are unchanged. A focused runtime regression executes the actual shared validator for English/Spanish lost-phone answers, resend, later and a short code, requiring no expression failures and no authentication. Tenant execution checks cover the affected account, identity, statement-delivery and proposal paths.
|
|
208
208
|
|
|
209
209
|
This corrective release follows the same authorized dev-only rollout: exact consumer pins, matched shared and ai-ar-ql definitions in all three session-only dev configurations, and production unchanged. Broader execution and model results are retained separately and must be reported as observed.
|
|
210
|
+
|
|
211
|
+
## 1.11.0 — the reply ranker replaces ContinueCurrent
|
|
212
|
+
|
|
213
|
+
A contextual DISPATCH (`onResponse: "continue"`) now asks one question: what does the reply do with the step's question? The reply ranker (`jsfe/host` `buildReplyRank`, task `rank_reply`, 4th aiCallback argument with `llm` and `systemOne` instructions) answers `answers`, `abort` or `new_request`; nothing clear is `answers`. `answers` continues the flow, extracting declared `responseParameters` with a stage-2 request; `abort` is a RETURN: every flow ends and the reply is the new system message `cmd_request_aborted` ("The process has been cancelled at your request - how can I help you?"; es: "El proceso fue cancelado a petición suya - ¿cómo puedo ayudarle?"), outcome `user_aborted` (new: transaction failed, never a post-mortem), no recovery stamp; `new_request` ends them and routes the input through intent detection. Only a new request is routed: after `answers` a later ordinary DISPATCH in the same turn goes to the host unrouted, so an answer never restarts its own flow.
|
|
214
|
+
|
|
215
|
+
Removed: the synthetic `ContinueCurrent` flow offered beside the primaries, its reserved name, and the per-turn routing cache shared between contextual and ordinary dispatch. Flow attributes and the `jsfe:contextual-responses:v1` protocol are unchanged. A host that does not know `rank_reply` answers its `llm` instructions with a text model. ReadFormResponse's question no longer sends uncertain replies to the host.
|
|
216
|
+
|
|
217
|
+
## 1.11.1 — interruption uses the primary intent detection
|
|
218
|
+
|
|
219
|
+
In a flow with `interruptable: true`, a reply is checked with the same intent detection as a new request (`detectFlowWithParameters`: two-stage when `engine.intentDetection = 'two-stage'`, so Jev with its LLM hedge): a detected primary interrupts on a new stack, started with the arguments stage 2 extracted. Removed: the separate `intent_analysis` call (`analyzeIntentStrength`).
|
|
220
|
+
|
|
221
|
+
**EXIT is the whole answer.** Every EXIT in the authentication flows (the contact prompt, the verification code, the
|
|
222
|
+
retry menus, the email prompt) now uses `isExitRequest`: the whole answer is exit, quit, abort, salir, cancel or cancelar
|
|
223
|
+
(with "please" / "por favor"), or `*`. A sentence that contains one ("Cancelar el pedido" at the code prompt) is not an
|
|
224
|
+
exit; at the code prompt it gets the "Let me explain" re-ask. Before, any answer containing the word (or, at the contact
|
|
225
|
+
prompt, any answer of at most two words containing it) ended the authentication in contact-support.
|
|
226
|
+
|
|
227
|
+
**A confident answer is never lost.** The host may add `fallback: true` to a `rank_reply` reply that is `answers` only
|
|
228
|
+
because no option cleared the cut. Without it the answer is confident: when the step declares one parameter marked `verbatim: true` (new, an open
|
|
229
|
+
string only; ReadFormResponse's answer is marked) and the stage-2 extraction finds nothing, or fails, the field is the
|
|
230
|
+
reply as said. An unmarked field always needs a real extraction (prod
|
|
231
|
+
2026-10-26: a cancellation reason ranked `answers`, extracted empty and went to the host,
|
|
232
|
+
whose guru answered it). A `fallback` answer leaves extraction as the judge; ReadFormResponse then rejects the reading
|
|
233
|
+
quietly and the form asks its question again, instead of handing the turn to the host.
|
|
234
|
+
|
|
235
|
+
**"asesor" / "asesora" are AGENT words.**
|
|
236
|
+
|
|
237
|
+
**A form gives up only for a new intent.** An answer a field's reader cannot read (three or more words, or any words at an
|
|
238
|
+
amount) no longer goes straight to the host with a plain DISPATCH: the reply ranker decides (`read-form-choice` for a
|
|
239
|
+
choice field, `read-form-reply` otherwise). `answers`: a choice takes the offered key the reply picks (stage-2 extraction
|
|
240
|
+
of `key`, checked against the offered keys), else the question is asked again with "Sorry, I didn't catch that.",
|
|
241
|
+
counted as an attempt, so `max_attempts` still bounds it; `abort` ends every flow; `new_request` goes to intent
|
|
242
|
+
detection. A host fixture that answered such turns with intent detection only must now also answer `rank_reply`.
|
|
243
|
+
|
|
244
|
+
**A press verb before a key picks it:** "ya oprimí el uno", "I already pressed one", "marqué el 2" (not after a negation).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jsfe",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.1",
|
|
4
4
|
"description": "TypeScript workflow engine for conversational/automation apps: multi-flow orchestration, interrupt/resume, stack-based control, robust errors, and flexible tool/REST integration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ron Pinkas",
|