@fiodos/web-core 0.1.19 → 0.1.21

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.
@@ -147,6 +147,9 @@ export function createFiodosBackendClient(options) {
147
147
  intent: request.lastStep.intent,
148
148
  ...(request.lastStep.label ? { label: request.lastStep.label } : {}),
149
149
  success: request.lastStep.success,
150
+ // Technical error of a FAILED step: the backend renders it in the
151
+ // RECOVERY block so the model can diagnose and change path.
152
+ ...(request.lastStep.error ? { error: request.lastStep.error } : {}),
150
153
  // Bounded result payload of the executed step (product search
151
154
  // results…): the backend renders it so the model can read real
152
155
  // ids for the NEXT step's parameters.
@@ -1,3 +1,5 @@
1
+ /** Bound on the technical error string shipped with action_failed events. */
2
+ const MAX_ERROR_DETAIL_CHARS = 200;
1
3
  function mapEvent(params) {
2
4
  const intent = params.intentDetected ?? null;
3
5
  const sessionId = params.sessionId ?? null;
@@ -13,6 +15,19 @@ function mapEvent(params) {
13
15
  }
14
16
  return events;
15
17
  }
18
+ // Failures are the events that make field reports diagnosable: without
19
+ // them, "the orb said it couldn't do it" leaves the developer blind. Only
20
+ // the technical error string travels (HTTP status + path, template
21
+ // param…), never any message content.
22
+ case 'action_failed':
23
+ return [
24
+ {
25
+ type: 'action_failed',
26
+ intent,
27
+ session_id: sessionId,
28
+ error: (params.errorDetail ?? '').slice(0, MAX_ERROR_DETAIL_CHARS) || null,
29
+ },
30
+ ];
16
31
  case 'action_confirmation_requested':
17
32
  return [{ type: 'confirmation_requested', intent, session_id: sessionId }];
18
33
  default:
@@ -25,6 +25,13 @@ import { createSpeechSession } from '../speech/speechSession.js';
25
25
  import { classifyPendingReply, decideAction, } from '../core/turnEngine.js';
26
26
  import { DEFAULT_AGENT_TIMINGS, DEFAULT_AGENT_CHAINING, } from '../config/types.js';
27
27
  import { resolveUiMessages } from '../ui/messages.js';
28
+ /**
29
+ * How many FAILED-step recovery turns one chain may consume. A failed action
30
+ * (invented handle → 404, wrong id kind → 422, transient 5xx) no longer kills
31
+ * the task: the model gets the technical error back and picks a different
32
+ * path — bounded so a persistently failing action can never loop.
33
+ */
34
+ const MAX_CHAIN_RECOVERIES = 2;
28
35
  function randomSessionId() {
29
36
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
30
37
  }
@@ -491,7 +498,11 @@ export class AgentController {
491
498
  intentDetected: pending.intent,
492
499
  sessionId: this.sessionId,
493
500
  actionExecuted: { intent: pending.intent, confirmed: true },
501
+ ...(!result.success ? { errorDetail: result.error ?? null } : {}),
494
502
  });
503
+ if (!result.success) {
504
+ console.warn(`[fyodos] action "${pending.intent}" failed: ${result.error ?? 'unknown error'}`);
505
+ }
495
506
  if (result.message) {
496
507
  if (this.lastExchange)
497
508
  this.lastExchange = { ...this.lastExchange, replyText: result.message };
@@ -518,6 +529,28 @@ export class AgentController {
518
529
  step: chainState.step,
519
530
  lastStep: chainState.pendingStep ?? chainState.lastStep,
520
531
  visitedKeys: chainState.visitedKeys,
532
+ recoveries: chainState.recoveries,
533
+ });
534
+ }
535
+ else if (!result.success && this.chaining.enabled) {
536
+ // The CONFIRMED action failed: same recovery contract as any other
537
+ // failed step — the model reads the technical error and takes a
538
+ // different path (bounded by the recovery budget). The user's
539
+ // confirmation covered THIS intent; a recovery that re-attempts it
540
+ // stages a NEW confirmation, so the human gate is never bypassed.
541
+ const failedStep = {
542
+ type: 'execute',
543
+ intent: pending.intent,
544
+ label: this.actionLabel(pending.intent),
545
+ success: false,
546
+ ...(result.error ? { error: String(result.error).slice(0, 300) } : {}),
547
+ };
548
+ await this.runChainLoop({
549
+ userMessage: chainState.userMessage,
550
+ step: chainState.step,
551
+ lastStep: failedStep,
552
+ visitedKeys: chainState.visitedKeys,
553
+ recoveries: (chainState.recoveries ?? 0) + 1,
521
554
  });
522
555
  }
523
556
  else if (!result.success) {
@@ -685,6 +718,7 @@ export class AgentController {
685
718
  let pendingInProgress = false;
686
719
  let pendingStep;
687
720
  let pendingKey;
721
+ let stepFailed = false;
688
722
  if (decision.kind === 'executed') {
689
723
  const r = decision.result;
690
724
  // REAL activity for the panel: this action actually just ran — the
@@ -719,6 +753,7 @@ export class AgentController {
719
753
  intentDetected: turn.action.intent,
720
754
  sessionId: this.sessionId,
721
755
  actionExecuted: { intent: turn.action.intent, confirmed: false },
756
+ ...(!r.success && !r.recoverable ? { errorDetail: r.error ?? null } : {}),
722
757
  });
723
758
  }
724
759
  if (r.recoverable) {
@@ -741,6 +776,30 @@ export class AgentController {
741
776
  outReply = this.messages.actionFailed;
742
777
  outAudio = null;
743
778
  }
779
+ if (!r.success) {
780
+ // Developer signal: the user only hears a generic failure phrase; the
781
+ // TECHNICAL cause (HTTP status + path, missing template param…) must
782
+ // be findable in the console or every field report is undebuggable.
783
+ console.warn(`[fyodos] action "${turn.action?.intent ?? '?'}" failed: ${r.error ?? 'unknown error'}`);
784
+ }
785
+ // RECOVERY signal: the step RAN and FAILED (not a recoverable guidance
786
+ // case). Feed the technical error back as a failed lastStep so the model
787
+ // can diagnose (invented handle → 404, wrong id kind → 422…) and take a
788
+ // DIFFERENT path, exactly like a human agent. The chain loop bounds how
789
+ // many of these turns run (MAX_CHAIN_RECOVERIES); task_status is
790
+ // irrelevant here — the model believed the step would succeed.
791
+ if (turn.action?.type === 'execute' && !r.success && !r.recoverable) {
792
+ const intent = turn.action.intent ?? '';
793
+ lastStep = {
794
+ type: 'execute',
795
+ intent,
796
+ label: this.actionLabel(intent),
797
+ success: false,
798
+ ...(r.error ? { error: String(r.error).slice(0, 300) } : {}),
799
+ };
800
+ stepFailed = true;
801
+ canContinue = this.chaining.enabled;
802
+ }
744
803
  // Chain signal: only a cleanly EXECUTED, non-recoverable step may advance,
745
804
  // and only when the backend asked to continue (task_status=in_progress).
746
805
  if (turn.action &&
@@ -816,6 +875,7 @@ export class AgentController {
816
875
  pendingInProgress,
817
876
  pendingStep,
818
877
  pendingKey,
878
+ stepFailed,
819
879
  };
820
880
  }
821
881
  /**
@@ -826,6 +886,7 @@ export class AgentController {
826
886
  async runChainLoop(state) {
827
887
  let step = state.step;
828
888
  let lastStep = state.lastStep;
889
+ let recoveries = state.recoveries ?? 0;
829
890
  const visitedKeys = state.visitedKeys;
830
891
  // Keep the panel's live row visible for the WHOLE chain (including the
831
892
  // dwell between steps), so the real narration never flickers away.
@@ -922,6 +983,7 @@ export class AgentController {
922
983
  visitedKeys,
923
984
  pendingKey: oc.pendingKey,
924
985
  pendingStep: oc.pendingStep,
986
+ recoveries,
925
987
  }
926
988
  : null;
927
989
  this.beginConfirmationVoiceWindow();
@@ -929,6 +991,21 @@ export class AgentController {
929
991
  }
930
992
  if (this.phase !== 'speaking')
931
993
  this.setPhase('idle');
994
+ // Failed step: consume one recovery slot. The budget is what keeps a
995
+ // persistently failing action from looping; a chain that exhausts it
996
+ // ends with the (honest) failure reply already on screen.
997
+ if (oc.stepFailed) {
998
+ recoveries += 1;
999
+ if (recoveries > MAX_CHAIN_RECOVERIES) {
1000
+ this.telemetry.logEvent({
1001
+ eventType: 'agent_chain_aborted',
1002
+ sessionId: this.sessionId,
1003
+ chainStep: step,
1004
+ chainAbortReason: 'no_progress',
1005
+ });
1006
+ return;
1007
+ }
1008
+ }
932
1009
  if (oc.actionKey && visitedKeys.has(oc.actionKey)) {
933
1010
  this.telemetry.logEvent({
934
1011
  eventType: 'agent_chain_aborted',
@@ -1111,7 +1188,8 @@ export class AgentController {
1111
1188
  this.setPhase('idle');
1112
1189
  }
1113
1190
  // Kick off the autonomous chain when the first step executed cleanly and
1114
- // the backend asked to continue (no sensitive confirmation pending).
1191
+ // the backend asked to continue (no sensitive confirmation pending) — or
1192
+ // when the first step FAILED and a recovery turn should diagnose it.
1115
1193
  if (this.chaining.enabled && outcome.canContinue && !hasPending) {
1116
1194
  this.telemetry.logEvent({
1117
1195
  eventType: 'agent_chain_started',
@@ -1126,6 +1204,7 @@ export class AgentController {
1126
1204
  step: 1,
1127
1205
  lastStep: outcome.lastStep,
1128
1206
  visitedKeys,
1207
+ recoveries: outcome.stepFailed ? 1 : 0,
1129
1208
  });
1130
1209
  }
1131
1210
  // Final reply is on screen: the live narrative disappears (unless the
@@ -27,7 +27,7 @@ import { createScreenContextStore } from '../context/screenContextStore.js';
27
27
  import { connectDynamicsFormContext, dynamicsFormSnapshot, normalizeDynamicsId } from './dynamics.js';
28
28
  import { buildApiActionRegistries, validateManifest } from '@fiodos/core';
29
29
  declare const api: {
30
- readonly version: "0.1.19";
30
+ readonly version: "0.1.21";
31
31
  readonly createFiodosAgent: typeof createFiodosAgent;
32
32
  readonly mountOrb: typeof mountOrb;
33
33
  readonly AgentController: typeof AgentController;
@@ -16,7 +16,7 @@
16
16
  * - CONFIRMATIONS: the same security model (manifest decides; strict phrase /
17
17
  * deliberate tap for payment-grade; loose "yes" never resolves strict).
18
18
  */
19
- import { DEFAULT_ORB_SCREEN_POSITION, createWebOrbHaptics, matchRouteIntent, resolveBubblePrompt, bubblePromptToAffirmative, resolveBubbleTheme, resolveBubblePadding, bubbleShapeToCss, applyBubbleShapeCss, resolveOrbAnchorPx, resolveOrbDeployment, snapOrbScreenPositionToSide, viewportPixelsToOrbScreenPosition, fiodosInputInkColor, fiodosInputMutedInkColor, shouldHideInternalOrb, } from '@fiodos/core';
19
+ import { DEFAULT_ORB_SCREEN_POSITION, createWebOrbHaptics, matchRouteIntent, resolveBubblePrompt, resolveBubbleTheme, resolveBubblePadding, bubbleShapeToCss, applyBubbleShapeCss, resolveOrbAnchorPx, resolveOrbDeployment, snapOrbScreenPositionToSide, viewportPixelsToOrbScreenPosition, fiodosInputInkColor, fiodosInputMutedInkColor, shouldHideInternalOrb, } from '@fiodos/core';
20
20
  import { buildKeyboardChip, createOrbVisual, DEFAULT_ORB_APPEARANCE, } from './orbView.js';
21
21
  import { watchPublishedConfig } from './publishedConfig.js';
22
22
  import { createBubbleDictation, scrollTextInputToEnd } from '../speech/bubbleDictation.js';
@@ -54,6 +54,8 @@ const CSS = `
54
54
  .fyodos-bubble-exchange:first-child .fyodos-bubble-user{padding-right:22px;}
55
55
  .fyodos-bubble-user{flex:1;min-width:0;font-size:13px;line-height:18px;opacity:.75;}
56
56
  .fyodos-bubble-reply{font-size:15px;line-height:21px;white-space:pre-wrap;}
57
+ .fyodos-bubble-greeting{font-size:15px;line-height:21px;white-space:pre-wrap;animation:fyodos-fade-in .18s ease-out;}
58
+ .fyodos-bubble--expanded .fyodos-bubble-greeting{font-size:17px;line-height:25px;}
57
59
  .fyodos-bubble-busy{display:flex;gap:5px;padding:8px 14px;align-items:center;}
58
60
  .fyodos-bubble-busy span{width:6px;height:6px;border-radius:50%;opacity:.5;animation:fyodos-blink 1s infinite;}
59
61
  .fyodos-bubble-busy span:nth-child(2){animation-delay:.18s;}
@@ -207,13 +209,9 @@ export function mountOrb(controller, options = {}) {
207
209
  haptics.trigger('orbTap');
208
210
  controller.primeAudio();
209
211
  if (enableTextInput) {
210
- const prompt = resolveCurrentBubblePrompt();
211
- openTextPanel(prompt
212
- ? bubblePromptToAffirmative(prompt, {
213
- locale: currentBubbleLocale(),
214
- messages: controller.messages,
215
- })
216
- : undefined);
212
+ // The bubble's question moves INTO the panel as an assistant greeting
213
+ // (openTextPanel shows it) — never pre-typed into the user's input.
214
+ openTextPanel();
217
215
  return;
218
216
  }
219
217
  userTriedVoice = true;
@@ -455,7 +453,30 @@ export function mountOrb(controller, options = {}) {
455
453
  loaderRow.style.display = 'none';
456
454
  const exchangeList = document.createElement('div');
457
455
  exchangeList.className = 'fyodos-bubble-exchanges';
458
- exchangeScroll.append(loaderRow, exchangeList);
456
+ // Screen-aware GREETING: the thought bubble's question, shown INSIDE the
457
+ // panel as an assistant message when it opens (tapping the bubble or opening
458
+ // the chat traditionally). Ephemeral by design: it never enters the
459
+ // conversation history and disappears the moment the user sends a message.
460
+ const greetingEl = document.createElement('div');
461
+ greetingEl.className = 'fyodos-bubble-greeting';
462
+ greetingEl.style.display = 'none';
463
+ exchangeScroll.append(loaderRow, exchangeList, greetingEl);
464
+ function showPanelGreeting() {
465
+ const prompt = resolveCurrentBubblePrompt();
466
+ if (!prompt) {
467
+ greetingEl.style.display = 'none';
468
+ return;
469
+ }
470
+ const t = { ...DEFAULT_MODAL_THEME, ...(modalTheme ?? {}) };
471
+ greetingEl.textContent = prompt;
472
+ greetingEl.style.color = t.titleColor;
473
+ greetingEl.style.display = '';
474
+ exchangeScroll.scrollTop = exchangeScroll.scrollHeight;
475
+ }
476
+ function hidePanelGreeting() {
477
+ if (greetingEl.style.display !== 'none')
478
+ greetingEl.style.display = 'none';
479
+ }
459
480
  // Signature of the rendered list so state emits that do not change the
460
481
  // transcript (volume, phase…) never re-render it nor fight the user's scroll.
461
482
  let renderedSignature = '';
@@ -749,6 +770,8 @@ export function mountOrb(controller, options = {}) {
749
770
  else {
750
771
  bubbleDictation.stop();
751
772
  syncMicButton();
773
+ // Ephemeral greeting never survives a close (recomputed on next open).
774
+ hidePanelGreeting();
752
775
  // Collapse the paged history back to the initial page for the next open.
753
776
  controller.resetBubbleWindow();
754
777
  // Closing the panel returns the orb to idle (also clears mic fallback).
@@ -778,6 +801,9 @@ export function mountOrb(controller, options = {}) {
778
801
  syncMicButton();
779
802
  haptics.trigger('send');
780
803
  textInput.value = '';
804
+ // The greeting is a welcome, not part of the conversation: the user's
805
+ // first real message replaces it.
806
+ hidePanelGreeting();
781
807
  // Silent turn: the reply is SHOWN in the bubble (not spoken), and the bubble
782
808
  // STAYS OPEN. This is the unified, reference-matching behaviour.
783
809
  void controller.submitTextTurn(value);
@@ -790,6 +816,11 @@ export function mountOrb(controller, options = {}) {
790
816
  const draft = explicitSeed || cancelled || '';
791
817
  textInput.value = draft;
792
818
  setBubble(true);
819
+ // Welcome the user with this screen's bubble question as an assistant
820
+ // message (both open paths: bubble tap AND traditional open). It vanishes
821
+ // with the user's first message — never enters the history.
822
+ if (!draft)
823
+ showPanelGreeting();
793
824
  // Only an explicit seed may auto-send; a restored cancelled message must
794
825
  // wait for the user to edit/resend it.
795
826
  if (autoSend && explicitSeed) {
@@ -1153,6 +1184,9 @@ export function mountOrb(controller, options = {}) {
1153
1184
  syncActivityStep(rowBusy, state);
1154
1185
  // Text bubble: show recent exchanges + busy state.
1155
1186
  if (bubbleOpen) {
1187
+ // A turn in flight (typed OR voice) replaces the ephemeral greeting.
1188
+ if (state.isBusy)
1189
+ hidePanelGreeting();
1156
1190
  loaderRow.style.display = state.loadingOlderExchanges ? 'flex' : 'none';
1157
1191
  renderBubbleExchanges(state.recentExchanges);
1158
1192
  exchangeScroll.style.display = 'flex';
@@ -5,5 +5,5 @@
5
5
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
6
6
  * by hand — change package.json `version` and re-run the sync/release script.
7
7
  */
8
- export declare const SDK_VERSION = "0.1.19";
8
+ export declare const SDK_VERSION = "0.1.21";
9
9
  export declare const SDK_PACKAGE = "@fiodos/web-core";
@@ -5,5 +5,5 @@
5
5
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
6
6
  * by hand — change package.json `version` and re-run the sync/release script.
7
7
  */
8
- export const SDK_VERSION = '0.1.19';
8
+ export const SDK_VERSION = '0.1.21';
9
9
  export const SDK_PACKAGE = '@fiodos/web-core';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiodos/web-core",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Framework-agnostic browser layer for the Fiodos agent: a vanilla AgentController (orchestrator), DOM adapters (navigation/voice/storage), HTTP client and decision engine over @fiodos/core. Shared base for @fiodos/vue, @fiodos/svelte and @fiodos/angular (no framework imports).",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": {