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/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 || 'unresolved');
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', detected) {
2173
- const result = detected === undefined ? await detectFlowWithParameters(input, engine) : detected;
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
- // ContinueCurrent retains the stack; a workflow replaces it; None hands off to the host.
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, responseContext) {
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, responseContext) {
2923
- const { input: cleanInput, conversation } = buildDetectFlowRequest(input, engine.lastChatTurn, [], responseContext);
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, continuation, responseContext) {
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 = (primaryFlows.length > 0 ? primaryFlows : flowsMenu).concat(continuation ? [continuation] : []);
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, responseContext);
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
- if (responseContext !== undefined)
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.concat(continuation ? [continuation] : []).find(flow => flow.name.toLowerCase() === aiResponse.flowName.toLowerCase() || flow.id === aiResponse.flowName);
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
- // Use AI to detect if this is a strong intent for a different flow
5689
- let intentAnalysis = null;
5690
- if (engine.aiCallback) {
5691
- intentAnalysis = await analyzeIntentStrength(input, engine);
5692
- logger.debug(`Intent analysis result:`, JSON.stringify(intentAnalysis));
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), // Fresh variables for interrupting flow activation
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
- let detectedResponseContext;
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 context = interpolateMessage(step.responseContext, frame.contextStack, frame.variables, engine).trim();
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
- const continuation = { id: '__jsfe_continue_current__', name: 'ContinueCurrent', version: '1.0.0', primary: true, steps: [], ...(declaration ? { parameters: declaration.parameters } : {}), description: `The caller is responding to the requested ${context} or reports that the requested information is invalid, unknown or unavailable; datos incorrectos o inválidos, no lo sé, no lo tengo, no puedo dar el dato. Includes asking how to provide the requested information. Not a different service request or unrelated question; choose its workflow when offered, otherwise None for the host.` };
5983
- let selected;
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
- selected = await route(continuation, context);
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
- // A contextual handoff must not restart its own live workflow without new input.
5992
- // The host has the broader dialogue needed to clarify this otherwise empty re-entry.
5993
- if (selected && selected.flow.id !== continuation.id && !Object.keys(selected.parameters || {}).length && engine.flowStacks.flat().some(active => active.flowId === selected.flow.id)) {
5994
- selected = null;
5995
- detected = null;
5996
- }
5997
- if (selected?.flow.id === continuation.id) {
5998
- if (declaration)
5999
- frame.variables[declaration.variable] = structuredClone(selected.parameters || {});
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
- stampStepRecovery(step, frame, engine);
6003
- terminateForDispatch(frame, engine);
6004
- if (!selected)
6005
- return true; // Real host handoff: ordinary dispatch consumes the cached None below.
6006
- engine.pendingDispatchFrom = null;
6007
- engine.recordDispatch({ fromFlow: frame.flowName, matchedFlow: selected.flow.name });
6008
- engine.clearFlowOutcome();
6009
- activatedThisTurn = true;
6010
- await isFlowActivated(String(sanitizedInput), engine, userId, selected);
6011
- return true;
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 (engine.pendingDispatchFrom === null) {
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 routed = await route();
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) {
@@ -1 +1 @@
1
- export const JSFE_RUNTIME_FINGERPRINT = "sha256:747832e9ef9aa989fff3e0af1692ca56be68abc6c591ea3112e86ea1d1e99bec";
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. Deterministic uncertainty may yield earlier, especially unrecognized monetary instructions. 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.
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 the existing intent/extraction model's interpretation. The prepared question and field description become contextual DISPATCH context. An extracted answer still passes deterministic validation; missing usable data yields with progress saved. A supported different intent routes to that flow; otherwise the host can answer with its full context. No separate field classifier/model role is needed. Do not attempt exhaustive conversational inference with keyword lists.
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. Contextual routing offers `ContinueCurrent` along with primaries and `None`. ContinueCurrent retains the live continuation and runs following recovery steps; an optional typed response writes a cloned normalized object before continuing. A named primary replaces the continuation; None hands off. 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.
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.10.9",
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",