Skip to content

Commit 59da455

Browse files
committed
feat: enhance user-facing updates for tool events and phases
- Introduced user-visible updates for tool events, phases, and states to improve user feedback. - Implemented mapping functions to convert internal events to user-friendly messages. - Updated the interactive UI to utilize the new user-facing tool status. - Enhanced the Telegram and Web UI gateways to send user-visible updates. - Added tests to ensure the correctness of user-visible update mappings and tool status exposure. - Refactored existing event handling to reduce noise and focus on actionable updates.
1 parent 9732235 commit 59da455

13 files changed

Lines changed: 1175 additions & 180 deletions

scripts/clean-yagr-local.mjs

Lines changed: 469 additions & 80 deletions
Large diffs are not rendered by default.

src/gateway/interactive-ui.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
resolveTerminalWorkflowOpenUrl,
1515
workflowEmbedKey,
1616
} from './format-message.js';
17+
import { getUserFacingToolStatus } from '../tools/observer.js';
1718
import type {
1819
YagrAgentState,
1920
YagrContextCompactionEvent,
@@ -462,11 +463,12 @@ function YagrInteractiveApp({ agent, options }: InteractiveAppProps) {
462463
}, [display.showResponses, pushEntry]);
463464

464465
const handleToolEvent = useCallback((event: YagrToolEvent) => {
465-
if (event.type === 'status' && event.toolName === 'reportProgress') {
466+
const userFacingStatus = getUserFacingToolStatus(event);
467+
if (userFacingStatus) {
466468
if (display.showThinking) {
467-
pushEntry('narrative', 'Progression', event.message);
469+
pushEntry('narrative', userFacingStatus.title, userFacingStatus.detail);
468470
}
469-
setActiveOperationText(event.message);
471+
setActiveOperationText(userFacingStatus.detail);
470472
return;
471473
}
472474

src/gateway/telegram.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import { YagrN8nConfigService } from '../config/n8n-config-service.js';
77
import type { EngineRuntimePort } from '../engine/engine.js';
88
import { YagrSetupApplicationService } from '../setup/application-services.js';
99
import type { YagrRequiredAction, YagrRunOptions } from '../types.js';
10+
import {
11+
mapPhaseEventToUserVisibleUpdate,
12+
mapStateEventToUserVisibleUpdate,
13+
mapToolEventToUserVisibleUpdate,
14+
type YagrUserVisibleUpdate,
15+
} from '../runtime/user-visible-updates.js';
1016
import {
1117
type WorkflowEmbed,
1218
buildWorkflowBannerHtml,
@@ -18,6 +24,12 @@ import type { Gateway, GatewayRuntimeHandle } from './types.js';
1824

1925
const TELEGRAM_MESSAGE_LIMIT = 4096;
2026

27+
function formatTelegramProgressHtml(update: YagrUserVisibleUpdate): string {
28+
const title = escapeHtml(update.title);
29+
const detail = update.detail ? escapeHtml(update.detail) : '';
30+
return detail ? `<b>${title}</b>\n${detail}` : `<b>${title}</b>`;
31+
}
32+
2133
export function createOnboardingToken(): string {
2234
return randomBytes(18).toString('base64url');
2335
}
@@ -465,14 +477,33 @@ class TelegramGateway implements Gateway {
465477
try {
466478
await reply('Yagr travaille...');
467479

480+
let lastProgressKey = '';
481+
const sendProgressUpdate = async (update: YagrUserVisibleUpdate | undefined): Promise<void> => {
482+
if (!update || update.dedupeKey === lastProgressKey) {
483+
return;
484+
}
485+
486+
lastProgressKey = update.dedupeKey;
487+
await this.sendHtml(chatId, formatTelegramProgressHtml(update));
488+
};
489+
468490
const embeds: WorkflowEmbed[] = [];
469491
const result = await (await this.getAgent(chatId)).run(prompt, {
470492
...this.options,
471493
display: undefined,
472494
satisfiedRequiredActionIds: satisfiedRequiredActions.map((action) => action.id),
495+
onPhaseChange: async (event) => {
496+
await sendProgressUpdate(mapPhaseEventToUserVisibleUpdate(event));
497+
await this.options.onPhaseChange?.(event);
498+
},
499+
onStateChange: async (event) => {
500+
await sendProgressUpdate(mapStateEventToUserVisibleUpdate(event));
501+
await this.options.onStateChange?.(event);
502+
},
473503
onToolEvent: async (event) => {
474504
const embed = extractWorkflowEmbed(event);
475505
if (embed) embeds.push(embed);
506+
await sendProgressUpdate(mapToolEventToUserVisibleUpdate(event));
476507
await this.options.onToolEvent?.(event);
477508
},
478509
});

src/gateway/webui.ts

Lines changed: 85 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ import {
2828
YAGR_SELECTABLE_MODEL_PROVIDERS,
2929
} from '../llm/provider-registry.js';
3030
import { resolveManagedN8nWorkflowOpen } from '../n8n-local/workflow-open.js';
31+
import {
32+
mapPhaseEventToUserVisibleUpdate,
33+
mapStateEventToUserVisibleUpdate,
34+
mapToolEventToUserVisibleUpdate,
35+
} from '../runtime/user-visible-updates.js';
3136
const __filename = fileURLToPath(import.meta.url);
3237
const __dirname = path.dirname(__filename);
3338

@@ -72,6 +77,63 @@ type WebUiChatStreamEvent =
7277
| { type: 'error'; error: string }
7378
| { type: 'embed'; kind: 'workflow'; workflowId: string; url: string; targetUrl?: string; title?: string; diagram?: string };
7479

80+
export function mapToolEventToWebUiStreamEvent(event: YagrToolEvent): WebUiChatStreamEvent | undefined {
81+
const userFacingStatus = mapToolEventToUserVisibleUpdate(event);
82+
if (userFacingStatus) {
83+
return {
84+
type: 'progress',
85+
tone: userFacingStatus.tone,
86+
title: userFacingStatus.title,
87+
detail: userFacingStatus.detail,
88+
...(userFacingStatus.phase ? { phase: userFacingStatus.phase } : {}),
89+
};
90+
}
91+
92+
if (event.type === 'embed') {
93+
return {
94+
type: 'embed',
95+
kind: event.kind,
96+
workflowId: event.workflowId,
97+
url: event.url,
98+
targetUrl: event.targetUrl,
99+
title: event.title,
100+
diagram: event.diagram,
101+
};
102+
}
103+
104+
return undefined;
105+
}
106+
107+
export function mapPhaseEventToWebUiStreamEvent(event: YagrPhaseEvent): WebUiChatStreamEvent | undefined {
108+
const update = mapPhaseEventToUserVisibleUpdate(event);
109+
if (!update) {
110+
return undefined;
111+
}
112+
113+
return {
114+
type: 'progress',
115+
tone: update.tone,
116+
title: update.title,
117+
detail: update.detail,
118+
...(update.phase ? { phase: update.phase } : {}),
119+
};
120+
}
121+
122+
export function mapStateEventToWebUiStreamEvent(event: YagrStateEvent): WebUiChatStreamEvent | undefined {
123+
const update = mapStateEventToUserVisibleUpdate(event);
124+
if (!update) {
125+
return undefined;
126+
}
127+
128+
return {
129+
type: 'progress',
130+
tone: update.tone,
131+
title: update.title,
132+
detail: update.detail,
133+
...(update.phase ? { phase: update.phase } : {}),
134+
};
135+
}
136+
75137
function isAbortError(error: unknown): boolean {
76138
return error instanceof Error && error.name === 'AbortError';
77139
}
@@ -495,66 +557,42 @@ class WebUiGateway implements Gateway {
495557
};
496558

497559
const pushPhaseEvent = (event: YagrPhaseEvent) => {
498-
if (event.status !== 'started') {
560+
const mappedEvent = mapPhaseEventToWebUiStreamEvent(event);
561+
if (mappedEvent) {
562+
writeEvent(mappedEvent);
499563
return;
500564
}
501565

502-
writeEvent({
503-
type: 'phase',
504-
phase: event.phase,
505-
status: event.status,
506-
message: event.message,
507-
});
508-
};
509-
510-
const pushStateEvent = (event: YagrStateEvent) => {
511-
if (event.state === 'running' || event.state === 'streaming' || event.state === 'completed') {
512-
return;
566+
if (event.status === 'started') {
567+
writeEvent({
568+
type: 'phase',
569+
phase: event.phase,
570+
status: event.status,
571+
message: event.message,
572+
});
513573
}
514-
515-
writeEvent({
516-
type: 'state',
517-
state: event.state,
518-
message: event.message,
519-
});
520574
};
521575

522-
const pushToolEvent = (event: YagrToolEvent) => {
523-
if (event.type === 'status') {
524-
writeEvent({
525-
type: 'progress',
526-
tone: 'info',
527-
title: event.toolName === 'reportProgress' ? 'Progress' : `Tool ${event.toolName}`,
528-
detail: event.message,
529-
});
576+
const pushStateEvent = (event: YagrStateEvent) => {
577+
const mappedEvent = mapStateEventToWebUiStreamEvent(event);
578+
if (mappedEvent) {
579+
writeEvent(mappedEvent);
530580
return;
531581
}
532582

533-
if (event.type === 'command-end') {
534-
if (event.exitCode === 0) {
535-
return;
536-
}
537-
583+
if (event.state !== 'running' && event.state !== 'streaming' && event.state !== 'completed') {
538584
writeEvent({
539-
type: 'progress',
540-
tone: 'info',
541-
title: 'Correcting commands',
542-
detail: event.message,
585+
type: 'state',
586+
state: event.state,
587+
message: event.message,
543588
});
544-
return;
545589
}
590+
};
546591

547-
if (event.type === 'embed') {
548-
writeEvent({
549-
type: 'embed',
550-
kind: event.kind,
551-
workflowId: event.workflowId,
552-
url: event.url,
553-
targetUrl: event.targetUrl,
554-
title: event.title,
555-
diagram: event.diagram,
556-
});
557-
return;
592+
const pushToolEvent = (event: YagrToolEvent) => {
593+
const mappedEvent = mapToolEventToWebUiStreamEvent(event);
594+
if (mappedEvent) {
595+
writeEvent(mappedEvent);
558596
}
559597
};
560598

src/runtime/run-engine.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,42 @@ export function buildGroundedSummary(
815815
return lines.join('\n');
816816
}
817817

818+
export function shouldForceGroundedFinalAnswer(
819+
journal: YagrRunJournalEntry[],
820+
requiredActions: YagrRequiredAction[] = [],
821+
): boolean {
822+
const outcome = analyzeRunOutcome(journal);
823+
const presentedWorkflow = extractPresentedWorkflowFromJournal(journal) ?? collectWorkflowPresentationFromOutcome(outcome);
824+
825+
if (requiredActions.length > 0) {
826+
return true;
827+
}
828+
829+
if (presentedWorkflow?.workflowUrl) {
830+
return true;
831+
}
832+
833+
return Boolean(outcome.hasWorkflowWrites && (outcome.successfulPush || outcome.successfulVerify));
834+
}
835+
836+
export function finalAnswerSatisfiesGroundedWorkflowFacts(
837+
text: string,
838+
journal: YagrRunJournalEntry[],
839+
): boolean {
840+
const normalizedText = sanitizeAssistantOutput(text);
841+
if (!normalizedText) {
842+
return false;
843+
}
844+
845+
const outcome = analyzeRunOutcome(journal);
846+
const presentedWorkflow = extractPresentedWorkflowFromJournal(journal) ?? collectWorkflowPresentationFromOutcome(outcome);
847+
if (presentedWorkflow?.workflowUrl && !normalizedText.includes(presentedWorkflow.workflowUrl)) {
848+
return false;
849+
}
850+
851+
return true;
852+
}
853+
818854
function buildFinalAnswerFacts(
819855
finishReason: string,
820856
journal: YagrRunJournalEntry[],
@@ -870,8 +906,9 @@ async function ensureFinalText(
870906
strategy: YagrToolRuntimeStrategy,
871907
): Promise<string> {
872908
const sanitizedText = sanitizeAssistantOutput(existingText);
909+
const forceGroundedFinalAnswer = shouldForceGroundedFinalAnswer(journal, requiredActions);
873910

874-
if (completionAccepted && sanitizedText && !looksLikeRawToolIntentText(sanitizedText)) {
911+
if (completionAccepted && !forceGroundedFinalAnswer && sanitizedText && !looksLikeRawToolIntentText(sanitizedText)) {
875912
return sanitizedText;
876913
}
877914

@@ -908,7 +945,11 @@ async function ensureFinalText(
908945
});
909946

910947
const finalText = sanitizeAssistantOutput(result.text);
911-
if (finalText && !looksLikeRawToolIntentText(finalText)) {
948+
if (
949+
finalText
950+
&& !looksLikeRawToolIntentText(finalText)
951+
&& finalAnswerSatisfiesGroundedWorkflowFacts(finalText, journal)
952+
) {
912953
return finalText;
913954
}
914955
} catch {

0 commit comments

Comments
 (0)