🧠 feat: Add Live Reasoning Labels (#14893)

* feat: add live reasoning labels

* fix: Stabilize reasoning label checks

* fix: Address reasoning label review findings

* chore: Bump Agents SDK for reasoning labels

* fix: Reset reused reasoning step evidence

* fix: Reconcile cleared reasoning labels

* fix: Fence reasoning label resets

* fix: Reset reasoning ownership before gap labels

* fix: Preserve THINK type through label reset

* test: Expect run-global reasoning revision
This commit is contained in:
Danny Avila 2026-08-16 18:11:55 -04:00 committed by GitHub
parent 3bd2358805
commit 7d850c308a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 3822 additions and 75 deletions

View file

@ -29,6 +29,7 @@ const {
supportsBalanceCheck,
isBedrockDocumentType,
getEndpointFileConfig,
stripReasoningLabelMetadata,
} = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { logViolation } = require('~/cache');
@ -595,6 +596,12 @@ class BaseClient {
const contentPart = latestMessage.content[index];
if (type === ContentTypes.THINK && contentPart.type === ContentTypes.THINK) {
contentPart[ContentTypes.THINK] = text;
delete contentPart.reasoning_label;
delete contentPart.reasoning_label_step_id;
delete contentPart.reasoning_label_attempts;
delete contentPart.reasoning_label_submitted_chars;
delete contentPart.reasoning_label_revision;
delete contentPart.reasoning_label_status;
} else if (type === ContentTypes.TEXT && contentPart.type === ContentTypes.TEXT) {
contentPart[ContentTypes.TEXT] = text;
}
@ -1324,7 +1331,15 @@ class BaseClient {
};
} else {
mergedContent[lastIndex] = {
...mergedContent[lastIndex],
...stripReasoningLabelMetadata(mergedContent[lastIndex]),
...(adjustedCompletion[0].reasoning_label_step_id != null && {
reasoning_label: adjustedCompletion[0].reasoning_label,
reasoning_label_step_id: adjustedCompletion[0].reasoning_label_step_id,
reasoning_label_attempts: adjustedCompletion[0].reasoning_label_attempts,
reasoning_label_submitted_chars: adjustedCompletion[0].reasoning_label_submitted_chars,
reasoning_label_revision: adjustedCompletion[0].reasoning_label_revision,
reasoning_label_status: adjustedCompletion[0].reasoning_label_status,
}),
[ContentTypes.THINK]:
(mergedContent[lastIndex][ContentTypes.THINK] || '') +
(adjustedCompletion[0][ContentTypes.THINK] || ''),

View file

@ -701,7 +701,17 @@ describe('BaseClient', () => {
messageId: responseMessageId,
parentMessageId: '3',
content: [
{ type: ContentTypes.THINK, think: 'Original reasoning', phase: 'analysis' },
{
type: ContentTypes.THINK,
think: 'Original reasoning',
phase: 'analysis',
reasoning_label: 'Inspecting the original path',
reasoning_label_step_id: 'old-step',
reasoning_label_attempts: 2,
reasoning_label_submitted_chars: 18,
reasoning_label_revision: 2,
reasoning_label_status: 'complete',
},
{ type: ContentTypes.TEXT, text: 'Original response' },
],
},
@ -1944,6 +1954,71 @@ describe('BaseClient', () => {
});
describe('mergeEditedContent phase boundaries', () => {
test('carries the new reasoning label when adjacent THINK parts merge', () => {
const existing = [
{
type: ContentTypes.THINK,
think: 'Retained reasoning. ',
reasoning_label: 'Inspecting the old path',
reasoning_label_step_id: 'old-step',
reasoning_label_attempts: 1,
reasoning_label_submitted_chars: 18,
reasoning_label_revision: 1,
reasoning_label_status: 'complete',
},
];
const completion = [
{
type: ContentTypes.THINK,
think: 'Continued reasoning.',
reasoning_label: 'Tracing the regenerated path',
reasoning_label_step_id: 'new-step',
reasoning_label_attempts: 3,
reasoning_label_submitted_chars: 20,
reasoning_label_revision: 2,
reasoning_label_status: 'streaming',
},
];
expect(TestClient.mergeEditedContent(existing, completion, ContentTypes.THINK)).toEqual([
{
...completion[0],
think: 'Retained reasoning. Continued reasoning.',
},
]);
});
test('clears a retained reasoning label when the merged THINK has no label', () => {
const existing = [
{
type: ContentTypes.THINK,
think: 'Retained reasoning. ',
agentId: 'agent-1',
reasoning_label: 'Inspecting the old path',
reasoning_label_step_id: 'old-step',
reasoning_label_attempts: 3,
reasoning_label_submitted_chars: 18,
reasoning_label_revision: 2,
reasoning_label_status: 'complete',
},
];
const completion = [
{
type: ContentTypes.THINK,
think: 'Continued without a generated title.',
agentId: 'agent-1',
},
];
expect(TestClient.mergeEditedContent(existing, completion, ContentTypes.THINK)).toEqual([
{
type: ContentTypes.THINK,
think: 'Retained reasoning. Continued without a generated title.',
agentId: 'agent-1',
},
]);
});
test('does not merge commentary into a final answer', () => {
const existing = [
{ type: ContentTypes.TEXT, text: 'Checked the deployment. ', phase: 'commentary' },

View file

@ -46,7 +46,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.6.0",
"@librechat/agents": "^3.6.1",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",

View file

@ -58,13 +58,18 @@ const {
stampSteerPartMedia,
createActivityLabelWiring,
createActivityPhaseWiring,
createReasoningLabelHostWiring,
generateReasoningLabelRevision,
getLabelUsageSequenceSeed,
createAssistantPhaseStampingHandlers,
resolveActivityConfig,
resolveActivityPhaseConfig,
resolveReasoningLabelConfig,
getCustomEndpointConfig,
mapCollectedMetadataToUsage,
resolveActivityLabelModel,
resolveActivityPhaseLabelModel,
resolveReasoningLabelModel,
traceIdForMessage,
settlePendingLabelFills,
stripActivityLabelParts,
@ -450,6 +455,35 @@ class AgentClient extends BaseClient {
return this.activityPhaseLabelLLMPromise;
}
/** Reasoning-label resolution is independently configurable and memoized per response. */
async resolveReasoningLabelLLM() {
this.reasoningLabelLLMPromise =
this.reasoningLabelLLMPromise ??
resolveReasoningLabelModel({
req: this.options.req,
agent: this.options.agent,
publicEndpoint: this.options.endpoint,
ids: {
messageId: this.responseMessageId,
conversationId: this.conversationId,
parentMessageId: this.parentMessageId,
},
db: { getUserKey: db.getUserKey, getUserKeyValues: db.getUserKeyValues },
}).catch((error) => {
this.reasoningLabelLLMPromise = null;
throw error;
});
return this.reasoningLabelLLMPromise;
}
/** Seeds the shared negative usage sequence from durable label-call state. */
seedActivityLabelUsageSequence() {
this.activityLabelUsageSeq = getLabelUsageSequenceSeed(
this.contentParts ?? [],
this.activityLabelUsageSeq,
);
}
/**
* Bills the label call and folds its usage into the response rollup with
* an `activity-label` tag (subagent precedent) so `metadata.usage` and the
@ -857,6 +891,61 @@ class AgentClient extends BaseClient {
};
}
/** SDK bridge for one revision of a live reasoning-step title. */
async generateReasoningLabelViaRun({
visibleReasoning,
reasoningStepId,
revision,
status,
previousLabel,
agentId,
charLimit,
prompt,
signal,
}) {
return generateReasoningLabelRevision({
payload: {
visibleReasoning,
reasoningStepId,
revision,
status,
...(previousLabel != null && { previousLabel }),
...(agentId != null && { agentId }),
charLimit,
...(prompt != null && { prompt }),
signal,
},
run: this.run,
resolveModel: () => this.resolveReasoningLabelLLM(),
sourceRunId: this.responseMessageId,
sourceTraceId: traceIdForMessage(this.responseMessageId),
responseId: this.responseMessageId,
sessionId: this.conversationId,
userId: this.user ?? this.options.req?.user?.id,
parentMessageId: this.parentMessageId,
recordUsage: ({
collectedMetadata,
model,
endpointTokenConfig,
sameEndpoint,
provider,
promptText,
completionText,
}) =>
this.recordActivityLabelUsage(
collectedMetadata,
model,
endpointTokenConfig,
sameEndpoint,
undefined,
provider,
() => ({ promptText, completionText }),
'reasoning-label',
),
onError: (error) => logger.warn('[AgentClient] Reasoning label generation failed', error),
});
}
/** Bounded settle for in-flight label fills before finalization. On
* timeout the label scope is closed and its abort controller fired, so a
* straggler cannot mutate the saved response or emit into a dead job. */
@ -871,20 +960,34 @@ class AgentClient extends BaseClient {
scope.detach?.();
}
};
const pending = this.pendingActivityLabelFills;
if (!pending || pending.length === 0) {
detachScopeListeners();
return;
}
this.pendingActivityLabelFills = [];
await settlePendingLabelFills(pending, timeoutMs, () => {
const closeScopes = () => {
/** Close EVERY generation's scope: a pre-pause wiring's straggler must
* stay closed even though a resume built a newer one. */
for (const scope of this.activityLabelScopes ?? []) {
scope.closed = true;
scope.abort.abort();
}
});
};
const deadline = Date.now() + timeoutMs;
while ((this.pendingActivityLabelFills?.length ?? 0) > 0) {
const pending = this.pendingActivityLabelFills;
this.pendingActivityLabelFills = [];
const remainingMs = Math.max(0, deadline - Date.now());
let timedOut = remainingMs === 0;
if (!timedOut) {
await settlePendingLabelFills(pending, remainingMs, () => {
timedOut = true;
closeScopes();
});
}
if (timedOut) {
closeScopes();
break;
}
/** A reasoning revision can synchronously enqueue its trailing final
* revision from the settled task's `finally`; drain it under the same
* deadline before any content reshaping can invalidate its index. */
}
detachScopeListeners();
}
@ -975,9 +1078,7 @@ class AgentClient extends BaseClient {
* the client's `runId:seq` deduper would discard the post-approval
* label's usage as already counted. Each label generation is a single
* non-streaming invoke, so one existing label part == one consumed seq. */
this.activityLabelUsageSeq =
this.activityLabelUsageSeq ??
(this.contentParts ?? []).filter((part) => part?.type === ContentTypes.ACTIVITY_LABEL).length;
this.seedActivityLabelUsageSequence();
this.activityLabelAbort = labelScope.abort;
/** An abort CLOSES the scope, not just cancels the call. The rejected
* generation still runs its catch and calls `fill(null)`; with the scope
@ -1124,9 +1225,7 @@ class AgentClient extends BaseClient {
scope.detach = () => abortSignal.removeEventListener('abort', closeOnAbort);
}
}
this.activityLabelUsageSeq =
this.activityLabelUsageSeq ??
(this.contentParts ?? []).filter((part) => part?.type === ContentTypes.ACTIVITY_LABEL).length;
this.seedActivityLabelUsageSequence();
const wiring = createActivityPhaseWiring({
maxPerRun: phaseConfig.maxPerRun,
@ -1164,6 +1263,72 @@ class AgentClient extends BaseClient {
return wiring;
}
/** Builds the independently opt-in live reasoning-label controller. */
buildReasoningLabelWiring(streamId, abortSignal, seedFromContent = false) {
if (!streamId || typeof Run?.prototype?.generateReasoningLabel !== 'function') {
return undefined;
}
const agentEndpoint = this.options.agent?.endpoint ?? '';
const appConfig = this.options.req?.config;
let customEndpointConfig;
try {
customEndpointConfig = getCustomEndpointConfig({ endpoint: agentEndpoint, appConfig });
} catch {
customEndpointConfig = undefined;
}
const config = resolveReasoningLabelConfig(
appConfig,
agentEndpoint,
customEndpointConfig,
this.options.endpoint,
);
if (!config.enabled) {
return undefined;
}
const shouldMarkResumable = this.activityLabelsMarkedPromise == null;
const { wiring, scope, markedPromise } = createReasoningLabelHostWiring({
config,
seedFromContent,
abortSignal,
...(shouldMarkResumable && {
markResumable: () => GenerationJobManager.markActivityLabels(streamId, this.jobCreatedAt),
onMarkFailure: () =>
logger.warn(
`[AgentClient] Could not flag reasoning labels for ${streamId}; an update resolving during a resume gap may not be reconciled.`,
),
}),
getContentParts: () => this.contentParts,
getStepIndex: (stepId) => this.stepMap?.get(stepId)?.index,
emitEvent: (event, data) =>
GenerationJobManager.emitChunk(
streamId,
{
event,
data: {
...data,
responseMessageId: this.responseMessageId,
conversationId: this.conversationId,
},
},
{ durable: true, expectedCreatedAt: this.jobCreatedAt },
),
trackPendingFill: (fillDone) => {
this.pendingActivityLabelFills = this.pendingActivityLabelFills ?? [];
this.pendingActivityLabelFills.push(fillDone);
},
generateLabel: (payload) => this.generateReasoningLabelViaRun(payload),
});
if (markedPromise != null) {
this.activityLabelsMarkedPromise = markedPromise;
}
this.activityLabelScopes = this.activityLabelScopes ?? [];
this.activityLabelScopes.push(scope);
this.seedActivityLabelUsageSequence();
this.reasoningLabelWiring = wiring;
return wiring;
}
/**
* `AgentClient` is not opinionated about vision requests, so we don't do anything here
* @param {MongoFile[]} attachments
@ -2875,10 +3040,14 @@ class AgentClient extends BaseClient {
const activityLabel = this.buildActivityLabelWiring(streamId, abortController.signal);
const activityPhase = this.buildActivityPhaseWiring(streamId, abortController.signal);
const reasoningLabel = this.buildReasoningLabelWiring(streamId, abortController.signal);
const offsetHandlers = createSteerIndexOffsetHandlers(
this.options.eventHandlers,
this.steerOffsetState,
);
const activityHandlers =
activityPhase?.handlers(offsetHandlers) ??
(activityLabel ? createAssistantPhaseStampingHandlers(offsetHandlers) : offsetHandlers);
const createRunPromise = createRun({
agents,
messages,
@ -2902,9 +3071,7 @@ class AgentClient extends BaseClient {
signal: abortController.signal,
/** The phase wrapper stays outermost: it claims and offsets the
* parent slot before the text step reaches the normal handlers. */
customHandlers:
activityPhase?.handlers(offsetHandlers) ??
(activityLabel ? createAssistantPhaseStampingHandlers(offsetHandlers) : offsetHandlers),
customHandlers: reasoningLabel?.handlers(activityHandlers) ?? activityHandlers,
requestBody: config.configurable.requestBody,
user: createSafeUser(this.options.req?.user),
tenantId: this.options.req?.user?.tenantId,
@ -2967,11 +3134,15 @@ class AgentClient extends BaseClient {
if (this.activityLabelsMarkedPromise != null) {
await this.activityLabelsMarkedPromise;
}
await run.processStream({ messages }, config, {
callbacks: {
[Callback.TOOL_ERROR]: logToolError,
},
});
try {
await run.processStream({ messages }, config, {
callbacks: {
[Callback.TOOL_ERROR]: logToolError,
},
});
} finally {
reasoningLabel?.complete();
}
this.completeActivityPhase(run, activityPhase);
// HITL: if the run paused for tool approval, mark the job
@ -3252,6 +3423,7 @@ class AgentClient extends BaseClient {
abortController.signal,
activityPhaseSnapshot,
);
const reasoningLabel = this.buildReasoningLabelWiring(streamId, abortController.signal, true);
const offsetHandlers = createSteerIndexOffsetHandlers(
createContentIndexOffsetHandlers(
this.options.eventHandlers,
@ -3259,6 +3431,9 @@ class AgentClient extends BaseClient {
),
this.steerOffsetState,
);
const activityHandlers =
activityPhase?.handlers(offsetHandlers) ??
(activityLabel ? createAssistantPhaseStampingHandlers(offsetHandlers) : offsetHandlers);
run = await createRun({
agents,
// State (messages, tool calls) is rehydrated from the checkpoint by
@ -3291,9 +3466,7 @@ class AgentClient extends BaseClient {
// type mismatch, is silently dropped against) the pre-pause content. The
// steer wrapper composes on top: resumed indices shift by seed + any
// steer parts spliced in while the resumed segment streams.
customHandlers:
activityPhase?.handlers(offsetHandlers) ??
(activityLabel ? createAssistantPhaseStampingHandlers(offsetHandlers) : offsetHandlers),
customHandlers: reasoningLabel?.handlers(activityHandlers) ?? activityHandlers,
requestBody: config.configurable.requestBody,
user: createSafeUser(this.options.req?.user),
tenantId: this.options.req?.user?.tenantId,
@ -3341,12 +3514,16 @@ class AgentClient extends BaseClient {
if (this.activityLabelsMarkedPromise != null) {
await this.activityLabelsMarkedPromise;
}
await run.resume(
resumeValue,
config,
{ callbacks: { [Callback.TOOL_ERROR]: logToolError } },
commandOptions,
);
try {
await run.resume(
resumeValue,
config,
{ callbacks: { [Callback.TOOL_ERROR]: logToolError } },
commandOptions,
);
} finally {
reasoningLabel?.complete();
}
this.completeActivityPhase(run, activityPhase);
config.signal = null;

View file

@ -59,6 +59,111 @@ jest.mock('@librechat/api', () => ({
maybePrewarmCodeSandbox: jest.fn(),
}));
describe('AgentClient - label settlement', () => {
it('drains a trailing fill enqueued by an in-flight reasoning revision', async () => {
const client = Object.create(AgentClient.prototype);
const first = deferred();
const trailing = deferred();
const scope = { closed: false, abort: new AbortController(), detach: jest.fn() };
client.activityLabelScopes = [scope];
client.pendingActivityLabelFills = [
first.promise.finally(() => {
client.pendingActivityLabelFills.push(trailing.promise);
}),
];
let settled = false;
const settlement = client.settleActivityLabels(1_000).then(() => {
settled = true;
});
first.resolve();
await new Promise((resolve) => setImmediate(resolve));
expect(settled).toBe(false);
trailing.resolve();
await settlement;
expect(scope.closed).toBe(false);
expect(scope.detach).toHaveBeenCalledTimes(1);
expect(client.pendingActivityLabelFills).toEqual([]);
});
});
describe('AgentClient - reasoning label accounting', () => {
function createReasoningLabelClient(generateReasoningLabel) {
const client = Object.create(AgentClient.prototype);
client.options = { req: { user: { id: 'user-123' } } };
client.conversationId = 'conversation-123';
client.parentMessageId = 'parent-123';
client.responseMessageId = 'response-123';
client.run = { generateReasoningLabel };
client.resolveReasoningLabelLLM = jest.fn(async () => ({
provider: Providers.OPENAI,
clientOptions: { model: 'reasoning-label-model' },
endpointTokenConfig: { input: 1, output: 2 },
sameEndpoint: false,
}));
client.recordActivityLabelUsage = jest.fn(async () => undefined);
return client;
}
it('estimates output tokens from the raw model completion before title normalization', async () => {
const rawCompletion = 'Inspecting the cache race\nThis extra explanation also consumed tokens.';
const client = createReasoningLabelClient(
jest.fn(async ({ chainOptions }) => {
const callback = chainOptions.callbacks[0];
callback.handleChatModelStart(undefined, [[{ content: 'captured SDK prompt' }]]);
callback.handleLLMEnd({
generations: [
[
{
text: rawCompletion,
message: { content: [{ type: 'text', text: rawCompletion }] },
},
],
],
});
return { label: 'Inspecting the cache race' };
}),
);
const generated = await client.generateReasoningLabelViaRun({
visibleReasoning: 'x'.repeat(500),
reasoningStepId: 'reasoning-1',
revision: 1,
status: 'streaming',
signal: new AbortController().signal,
});
await generated.collectUsage(generated.label);
const usageCall = client.recordActivityLabelUsage.mock.calls[0];
expect(usageCall[6]()).toEqual({
promptText: 'captured SDK prompt',
completionText: rawCompletion,
});
expect(usageCall[7]).toBe('reasoning-label');
});
it('falls back to the returned label when no raw completion callback is available', async () => {
const client = createReasoningLabelClient(
jest.fn(async () => ({ label: 'Inspecting the cache race' })),
);
const generated = await client.generateReasoningLabelViaRun({
visibleReasoning: 'x'.repeat(500),
reasoningStepId: 'reasoning-1',
revision: 1,
status: 'streaming',
signal: new AbortController().signal,
});
await generated.collectUsage(generated.label);
expect(client.recordActivityLabelUsage.mock.calls[0][6]()).toMatchObject({
completionText: 'Inspecting the cache race',
});
});
});
describe('AgentClient - interrupt discovery persistence', () => {
beforeEach(async () => {
await GenerationJobManager.destroy();

View file

@ -98,6 +98,44 @@ describe('PUT /:conversationId/:messageId content edit', () => {
});
});
it('clears the generated reasoning title when its reasoning text is edited', async () => {
getMessages.mockResolvedValue([
{
tokenCount: 10,
content: [
{
type: ContentTypes.THINK,
think: 'Original reasoning',
agentId: 'agent-1',
reasoning_label: 'Inspecting the original path',
reasoning_label_step_id: 'reasoning-step-1',
reasoning_label_attempts: 3,
reasoning_label_submitted_chars: 18,
reasoning_label_revision: 2,
reasoning_label_status: 'complete',
},
],
},
]);
const response = await request(app)
.put('/api/messages/conversation-1/message-1')
.send({ index: 0, text: 'Edited reasoning', model: 'gpt-5' });
expect(response.status).toBe(200);
expect(updateMessage).toHaveBeenCalledWith('user-1', {
messageId: 'message-1',
tokenCount: 10,
content: [
{
type: ContentTypes.THINK,
think: 'Edited reasoning',
agentId: 'agent-1',
},
],
});
});
/**
* A text part is `string | { value, annotations }`. The Assistants thread sync
* persists the structured form with its file citations intact and the editor reads

View file

@ -1,7 +1,12 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const { logger } = require('@librechat/data-schemas');
const { ContentTypes, feedbackSchema, isAssistantsEndpoint } = require('librechat-data-provider');
const {
ContentTypes,
feedbackSchema,
isAssistantsEndpoint,
stripReasoningLabelMetadata,
} = require('librechat-data-provider');
const {
unescapeLaTeX,
countTokens,
@ -418,10 +423,12 @@ router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) =
const currentValue = currentPart[currentPartType];
const isStructuredValue = currentValue != null && typeof currentValue === 'object';
const oldText = isStructuredValue ? (currentValue.value ?? '') : currentValue;
updatedContent[index] = {
const editedPart = {
...currentPart,
[currentPartType]: isStructuredValue ? { ...currentValue, value: text } : text,
};
updatedContent[index] =
currentPartType === ContentTypes.THINK ? stripReasoningLabelMetadata(editedPart) : editedPart;
let tokenCount = message.tokenCount;
if (tokenCount !== undefined) {

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { ContentTypes } from 'librechat-data-provider';
import { Alert, Button, TextareaAutosize } from '@librechat/client';
import { ContentTypes, stripReasoningLabelMetadata } from 'librechat-data-provider';
import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
import type { TMessageContentParts, TextData } from 'librechat-data-provider';
import type { ReactNode } from 'react';
@ -166,10 +166,13 @@ export default function EditContentParts({
if (!part || !change || part.type !== change.type) {
return part;
}
return {
const editedPart = {
...part,
[change.type]: withPartText(part, change.text),
} as TMessageContentParts;
return change.type === ContentTypes.THINK
? stripReasoningLabelMetadata(editedPart)
: editedPart;
}),
};
}),

View file

@ -144,7 +144,13 @@ const Part = memo(function Part({
if (typeof reasoning !== 'string') {
return null;
}
return <Reasoning reasoning={reasoning} isLast={isLast ?? false} />;
return (
<Reasoning
reasoning={reasoning}
isLast={isLast ?? false}
reasoningLabel={part.reasoning_label}
/>
);
} else if (part.type === ContentTypes.SUMMARY) {
return (
<Summary

View file

@ -12,6 +12,7 @@ import { cn } from '~/utils';
type ReasoningProps = {
reasoning: string;
isLast: boolean;
reasoningLabel?: string;
};
/**
@ -36,7 +37,8 @@ type ReasoningProps = {
*
* For legacy text-based messages, see Thinking.tsx component.
*/
const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
const Reasoning = memo((props: ReasoningProps) => {
const { reasoning, isLast, reasoningLabel } = props;
const contentId = useId();
const localize = useLocalize();
const showThinking = useAtomValue(showThinkingAtom);
@ -82,11 +84,15 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
const label = useMemo(
() =>
effectiveIsSubmitting && isLast ? localize('com_ui_thinking') : localize('com_ui_thoughts'),
[effectiveIsSubmitting, localize, isLast],
);
const label = useMemo(() => {
const generated = reasoningLabel?.trim();
if (generated) {
return generated;
}
return effectiveIsSubmitting && isLast
? localize('com_ui_thinking')
: localize('com_ui_thoughts');
}, [effectiveIsSubmitting, isLast, localize, reasoningLabel]);
if (!reasoningText) {
return null;
@ -109,6 +115,9 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
label={label}
content={reasoningText}
contentId={contentId}
animateLabel={
smoothStreaming && effectiveIsSubmitting && Boolean(reasoningLabel?.trim())
}
/>
</div>
<div

View file

@ -41,6 +41,7 @@ export const ThinkingButton = memo(
content,
contentId,
showCopyButton = true,
animateLabel = false,
}: {
isExpanded: boolean;
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
@ -48,6 +49,7 @@ export const ThinkingButton = memo(
content?: string;
contentId: string;
showCopyButton?: boolean;
animateLabel?: boolean;
}) => {
const localize = useLocalize();
const fontSize = useAtomValue(fontSizeAtom);
@ -91,7 +93,16 @@ export const ThinkingButton = memo(
aria-hidden="true"
/>
</span>
{label}
<span
key={label}
className={cn(
'min-w-0 truncate text-left',
animateLabel &&
'duration-300 ease-out animate-in fade-in-0 slide-in-from-bottom-1 motion-reduce:animate-none',
)}
>
{label}
</span>
</button>
{content && showCopyButton && (
<Button

View file

@ -124,6 +124,49 @@ describe('EditContentParts', () => {
expect(enterEdit).toHaveBeenCalledWith(true);
});
it('clears the cached reasoning title when its reasoning text is edited', async () => {
const reasoningContent = [
{
type: ContentTypes.THINK,
think: 'Original reasoning',
agentId: 'agent-1',
reasoning_label: 'Inspecting the original path',
reasoning_label_step_id: 'reasoning-step-1',
reasoning_label_attempts: 3,
reasoning_label_submitted_chars: 18,
reasoning_label_revision: 2,
reasoning_label_status: 'complete',
},
] as TMessageContentParts[];
message.content = reasoningContent;
render(
<EditContentParts
content={reasoningContent}
messageId={message.messageId}
isSubmitting={false}
enterEdit={jest.fn()}
siblingIdx={0}
setSiblingIdx={jest.fn()}
renderReadOnlyPart={() => null}
/>,
);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Edited reasoning' } });
fireEvent.click(screen.getByRole('button', { name: 'com_ui_save' }));
await waitFor(() => expect(mockSetMessages).toHaveBeenCalledTimes(1));
const reconciled = mockSetMessages.mock.calls[0][0] as TMessage[];
const edited = reconciled.find((item) => item.messageId === message.messageId);
expect(edited?.content).toEqual([
{
type: ContentTypes.THINK,
think: 'Edited reasoning',
agentId: 'agent-1',
},
]);
});
it('reruns an assistant response with the edited content value', () => {
const enterEdit = jest.fn();
render(

View file

@ -635,6 +635,12 @@ export default function useChatFunctions({
const contentPart = initialResponse.content[index];
if (type === ContentTypes.THINK && contentPart.type === ContentTypes.THINK) {
contentPart[ContentTypes.THINK] = part[ContentTypes.THINK];
delete contentPart.reasoning_label;
delete contentPart.reasoning_label_step_id;
delete contentPart.reasoning_label_attempts;
delete contentPart.reasoning_label_submitted_chars;
delete contentPart.reasoning_label_revision;
delete contentPart.reasoning_label_status;
} else if (type === ContentTypes.TEXT && contentPart.type === ContentTypes.TEXT) {
contentPart[ContentTypes.TEXT] = part[ContentTypes.TEXT];
}

View file

@ -1502,6 +1502,146 @@ describe('useStepHandler', () => {
);
});
it('preserves a live reasoning label across later deltas', () => {
const responseMessage = createResponseMessage();
mockGetMessages.mockReturnValue([responseMessage]);
const { result } = renderHook(() => useStepHandler(createHookParams()));
const runStep = createRunStep();
const submission = createSubmission();
act(() => {
result.current.stepHandler({ event: StepEvents.ON_RUN_STEP, data: runStep }, submission);
result.current.syncStepMessage({
...responseMessage,
content: [
{
type: ContentTypes.THINK,
think: 'First ',
reasoning_label: 'Tracing the streaming path',
reasoning_label_step_id: 'step-1',
reasoning_label_attempts: 1,
reasoning_label_submitted_chars: 6,
reasoning_label_revision: 1,
reasoning_label_status: 'streaming',
},
],
});
result.current.stepHandler(
{ event: StepEvents.ON_REASONING_DELTA, data: createReasoningDelta('step-1', 'thought') },
submission,
);
});
const lastCall = mockSetMessages.mock.calls[mockSetMessages.mock.calls.length - 1][0];
const responseMsg = lastCall[lastCall.length - 1];
expect(responseMsg.content).toContainEqual(
expect.objectContaining({
type: ContentTypes.THINK,
think: 'First thought',
reasoning_label: 'Tracing the streaming path',
reasoning_label_attempts: 1,
reasoning_label_submitted_chars: 6,
reasoning_label_revision: 1,
reasoning_label_status: 'streaming',
}),
);
});
it('clears a retained label when a new reasoning step folds into the same part', () => {
const responseMessage = createResponseMessage();
mockGetMessages.mockReturnValue([responseMessage]);
const { result } = renderHook(() => useStepHandler(createHookParams()));
const runStep = createRunStep();
const submission = createSubmission();
act(() => {
result.current.stepHandler({ event: StepEvents.ON_RUN_STEP, data: runStep }, submission);
result.current.syncStepMessage({
...responseMessage,
content: [
{
type: ContentTypes.THINK,
think: 'Retained ',
reasoning_label: 'Inspecting the old path',
reasoning_label_step_id: 'old-step',
reasoning_label_attempts: 3,
reasoning_label_submitted_chars: 9,
reasoning_label_revision: 3,
reasoning_label_status: 'complete',
},
],
});
result.current.stepHandler(
{ event: StepEvents.ON_REASONING_DELTA, data: createReasoningDelta('step-1', 'thought') },
submission,
);
});
const lastCall = mockSetMessages.mock.calls[mockSetMessages.mock.calls.length - 1][0];
const part = lastCall.at(-1)?.content?.[0];
expect(part).toMatchObject({
type: ContentTypes.THINK,
think: 'Retained thought',
reasoning_label_step_id: 'step-1',
});
expect(part).not.toHaveProperty('reasoning_label');
expect(part).not.toHaveProperty('reasoning_label_attempts');
expect(part).not.toHaveProperty('reasoning_label_submitted_chars');
expect(part).not.toHaveProperty('reasoning_label_revision');
});
it('clears a retained label when THINK arrives through a message delta', () => {
const responseMessage = createResponseMessage();
mockGetMessages.mockReturnValue([responseMessage]);
const { result } = renderHook(() => useStepHandler(createHookParams()));
const runStep = createRunStep();
const submission = createSubmission();
act(() => {
result.current.stepHandler({ event: StepEvents.ON_RUN_STEP, data: runStep }, submission);
result.current.syncStepMessage({
...responseMessage,
content: [
{
type: ContentTypes.THINK,
think: 'Retained ',
reasoning_label: 'Inspecting the old path',
reasoning_label_step_id: 'old-step',
reasoning_label_attempts: 3,
reasoning_label_submitted_chars: 9,
reasoning_label_revision: 3,
reasoning_label_status: 'complete',
},
],
});
result.current.stepHandler(
{
event: StepEvents.ON_MESSAGE_DELTA,
data: {
id: 'step-1',
delta: { content: [{ type: ContentTypes.THINK, think: 'thought' }] },
},
},
submission,
);
});
const lastCall = mockSetMessages.mock.calls[mockSetMessages.mock.calls.length - 1][0];
const part = lastCall.at(-1)?.content?.[0];
expect(part).toMatchObject({
type: ContentTypes.THINK,
think: 'Retained thought',
reasoning_label_step_id: 'step-1',
});
expect(part).not.toHaveProperty('reasoning_label');
expect(part).not.toHaveProperty('reasoning_label_attempts');
expect(part).not.toHaveProperty('reasoning_label_submitted_chars');
expect(part).not.toHaveProperty('reasoning_label_revision');
});
it('applies every entry of a multi-part reasoning delta in order', () => {
const responseMessage = createResponseMessage();
mockGetMessages.mockReturnValue([responseMessage]);

View file

@ -12,7 +12,9 @@ import {
apiBaseUrl,
SteerEvents,
dataService,
ContentTypes,
ActivityLabelEvents,
ReasoningLabelEvents,
UsageEvents,
createPayload,
ApprovalEvents,
@ -30,6 +32,7 @@ import type {
TSteerAppliedEvent,
TSteerUpdatedEvent,
TActivityLabelEvent,
TReasoningLabelEvent,
} from 'librechat-data-provider';
import type { ActiveJobsResponse, StreamStatusResponse } from '~/data-provider';
import type { DrainAfterAbort, QueuedMessageOrigin } from '~/store/families';
@ -45,8 +48,10 @@ import {
resolveRunEndTarget,
findSteerMessageIndex,
applyActivityLabelPart,
applyReasoningLabel,
offsetActivityPhaseBoundary,
findActivityLabelMessageIndex,
findReasoningLabelMessageIndex,
appendAppliedSteerIds,
collectAppliedSteerIds,
removeConvoFromAllQueries,
@ -1415,6 +1420,61 @@ export default function useResumableSSE(
}
};
/** Patches a generated title onto its existing reasoning part. */
const applyReasoningLabelToMessages = (event: TReasoningLabelEvent, attempt = 0) => {
if (!isCurrentSubscription()) {
return;
}
const retryNextFrame = () => {
if (attempt < PENDING_ACTION_MAX_RETRY_FRAMES) {
const frameId = requestAnimationFrame(() => {
activityLabelRetryFramesRef.current.delete(frameId);
if (isCurrentSubscription()) {
applyReasoningLabelToMessages(event, attempt + 1);
}
});
activityLabelRetryFramesRef.current.add(frameId);
}
};
flushPendingDeltas();
const messages = getMessages() ?? [];
const messageIndex = findReasoningLabelMessageIndex(messages, event);
if (messageIndex < 0) {
retryNextFrame();
return;
}
const prefixLength =
currentSubmission.editedContent != null && !editPrefixClearedRef.current
? (currentSubmission.editPrefixLength ??
(currentSubmission.initialResponse as TMessage | undefined)?.content?.length ??
0)
: 0;
let contentIndex = event.index + prefixLength;
if (
prefixLength > 0 &&
event.index === 0 &&
editPrefixFirstPartFoldedRef.current &&
messages[messageIndex]?.content?.[contentIndex - 1]?.type === ContentTypes.THINK
) {
contentIndex -= 1;
}
const updated = applyReasoningLabel(messages[messageIndex], {
...event,
index: contentIndex,
});
if (updated === messages[messageIndex]) {
const part = messages[messageIndex]?.content?.[contentIndex];
if (part?.type !== ContentTypes.THINK && attempt < PENDING_ACTION_MAX_RETRY_FRAMES) {
retryNextFrame();
}
return;
}
const nextMessages = [...messages];
nextMessages[messageIndex] = updated;
setMessages(nextMessages);
syncStepMessage(updated);
};
const baseUrl = `${apiBaseUrl()}/api/agents/chat/stream/${encodeURIComponent(currentStreamId)}`;
const query = new URLSearchParams();
if (isResume) {
@ -1664,6 +1724,15 @@ export default function useResumableSSE(
return;
}
if (data.event === ReasoningLabelEvents.ON_REASONING_LABEL) {
applyReasoningLabelToMessages(data.data as TReasoningLabelEvent);
return;
}
if (data.event === ReasoningLabelEvents.ON_REASONING_LABEL_ATTEMPT) {
return;
}
if (data.event != null) {
if (
data.event === StepEvents.ON_MESSAGE_DELTA ||
@ -1893,6 +1962,10 @@ export default function useResumableSSE(
updateSteerChips(replayEvent.data as TSteerUpdatedEvent);
} else if (replayEvent.event === ActivityLabelEvents.ON_ACTIVITY_LABEL) {
applyActivityLabelToMessages(replayEvent.data as TActivityLabelEvent);
} else if (replayEvent.event === ReasoningLabelEvents.ON_REASONING_LABEL) {
applyReasoningLabelToMessages(replayEvent.data as TReasoningLabelEvent);
} else if (replayEvent.event === ReasoningLabelEvents.ON_REASONING_LABEL_ATTEMPT) {
// Durable provider-call budget reservations never render.
} else if (replayEvent.event != null) {
if (
replayEvent.event === StepEvents.ON_MESSAGE_DELTA ||
@ -1926,6 +1999,10 @@ export default function useResumableSSE(
updateSteerChips(pendingEvent.data as TSteerUpdatedEvent);
} else if (pendingEvent.event === ActivityLabelEvents.ON_ACTIVITY_LABEL) {
applyActivityLabelToMessages(pendingEvent.data as TActivityLabelEvent);
} else if (pendingEvent.event === ReasoningLabelEvents.ON_REASONING_LABEL) {
applyReasoningLabelToMessages(pendingEvent.data as TReasoningLabelEvent);
} else if (pendingEvent.event === ReasoningLabelEvents.ON_REASONING_LABEL_ATTEMPT) {
// Durable provider-call budget reservations never render.
} else if (pendingEvent.event != null) {
if (
pendingEvent.event === StepEvents.ON_MESSAGE_DELTA ||

View file

@ -70,6 +70,27 @@ type MessageDeltaUpdate = {
type ReasoningDeltaUpdate = { type: ContentTypes.THINK; think: string };
/** Starts a fresh label-revision domain when a different reasoning step
* reuses or folds into an existing THINK slot. The step id is stamped before
* the first generated title so compacted resume snapshots can still correlate
* later label events by identity rather than relying only on a sparse index. */
function prepareReasoningPartForStep(message: TMessage, index: number, stepId: string): TMessage {
const current = message.content?.[index];
if (current?.type !== ContentTypes.THINK || current.reasoning_label_step_id === stepId) {
return message;
}
const nextPart = { ...current };
delete nextPart.reasoning_label;
delete nextPart.reasoning_label_attempts;
delete nextPart.reasoning_label_submitted_chars;
delete nextPart.reasoning_label_revision;
delete nextPart.reasoning_label_status;
nextPart.reasoning_label_step_id = stepId;
const nextContent = [...(message.content ?? [])];
nextContent[index] = nextPart;
return { ...message, content: nextContent };
}
type AllContentTypes =
| ContentTypes.TEXT
| ContentTypes.THINK
@ -485,6 +506,7 @@ export default function useStepHandler({
) {
const currentContent = updatedContent[index] as ReasoningDeltaUpdate;
const update: ReasoningDeltaUpdate = {
...currentContent,
type: ContentTypes.THINK,
think: (currentContent.think || '') + contentPart.think,
};
@ -994,6 +1016,13 @@ export default function useStepHandler({
) {
submission.editPrefixFirstPartFolded = true;
}
if (phasedContentPart.type === ContentTypes.THINK) {
updatedResponse = prepareReasoningPartForStep(
updatedResponse,
currentIndex,
messageDelta.id,
);
}
updatedResponse = updateContent(
updatedResponse,
currentIndex,
@ -1052,6 +1081,11 @@ export default function useStepHandler({
) {
submission.editPrefixFirstPartFolded = true;
}
updatedResponse = prepareReasoningPartForStep(
updatedResponse,
currentIndex,
reasoningDelta.id,
);
updatedResponse = updateContent(
updatedResponse,
currentIndex,

View file

@ -0,0 +1,205 @@
import { ContentTypes } from 'librechat-data-provider';
import type { TMessage, TReasoningLabelEvent } from 'librechat-data-provider';
import { applyReasoningLabel, findReasoningLabelMessageIndex } from '../reasoningLabels';
const baseEvent: TReasoningLabelEvent = {
index: 0,
stepId: 'reasoning-1',
revision: 1,
label: 'Tracing resume ownership',
status: 'streaming',
responseMessageId: 'response-1',
};
function response(): TMessage {
return {
messageId: 'response-1',
conversationId: 'conversation-1',
parentMessageId: 'user-1',
sender: 'Agent',
text: '',
isCreatedByUser: false,
content: [{ type: ContentTypes.THINK, think: 'Visible reasoning' }],
};
}
describe('reasoning label utilities', () => {
it('patches an existing reasoning part without moving content', () => {
const message = response();
const updated = applyReasoningLabel(message, baseEvent);
expect(updated).not.toBe(message);
expect(updated.content).toHaveLength(1);
expect(updated.content?.[0]).toMatchObject({
type: ContentTypes.THINK,
think: 'Visible reasoning',
reasoning_label: 'Tracing resume ownership',
reasoning_label_step_id: 'reasoning-1',
reasoning_label_revision: 1,
reasoning_label_status: 'streaming',
});
});
it('rejects stale and same-revision conflicting updates', () => {
const current = applyReasoningLabel(response(), { ...baseEvent, revision: 2 });
expect(
applyReasoningLabel(current, {
...baseEvent,
revision: 1,
label: 'Stale label',
}),
).toBe(current);
expect(
applyReasoningLabel(current, {
...baseEvent,
revision: 2,
label: 'Conflicting label',
}),
).toBe(current);
});
it('allows an equal-revision terminal status upgrade', () => {
const current = applyReasoningLabel(response(), baseEvent);
const completed = applyReasoningLabel(current, { ...baseEvent, status: 'complete' });
expect(completed.content?.[0]).toMatchObject({
reasoning_label_revision: 1,
reasoning_label_status: 'complete',
});
expect(applyReasoningLabel(completed, baseEvent)).toBe(completed);
});
it('clears a stale snapshot label when its THINK slot belongs to a new step', () => {
const oldStep = applyReasoningLabel(response(), {
...baseEvent,
stepId: 'old-step',
revision: 3,
label: 'Inspecting the old direction',
status: 'complete',
});
const updated = applyReasoningLabel(oldStep, {
index: 0,
stepId: 'new-step',
reset: true,
previousStepId: 'old-step',
attempts: 4,
responseMessageId: 'response-1',
});
expect(updated.content?.[0]).toMatchObject({
type: ContentTypes.THINK,
think: 'Visible reasoning',
reasoning_label_step_id: 'new-step',
reasoning_label_attempts: 4,
});
expect(updated.content?.[0]).not.toHaveProperty('reasoning_label');
expect(updated.content?.[0]).not.toHaveProperty('reasoning_label_revision');
expect(updated.content?.[0]).not.toHaveProperty('reasoning_label_status');
expect(
applyReasoningLabel(updated, {
index: 0,
stepId: 'new-step',
reset: true,
previousStepId: 'old-step',
}),
).toBe(updated);
});
it('ignores a reset after the new step already received a label', () => {
const newer = applyReasoningLabel(response(), {
...baseEvent,
stepId: 'new-step',
revision: 2,
label: 'Inspecting the new direction',
});
expect(
applyReasoningLabel(newer, {
index: 0,
stepId: 'new-step',
reset: true,
previousStepId: 'old-step',
attempts: 2,
}),
).toBe(newer);
});
it('applies an ownership reset before a replacement-step label', () => {
const oldStep = applyReasoningLabel(response(), {
...baseEvent,
stepId: 'old-step',
label: 'Inspecting the old direction',
});
const reset = applyReasoningLabel(oldStep, {
index: 0,
stepId: 'new-step',
reset: true,
previousStepId: 'old-step',
attempts: 2,
});
const updated = applyReasoningLabel(reset, {
...baseEvent,
stepId: 'new-step',
revision: 2,
label: 'Inspecting the new direction',
});
expect(updated.content?.[0]).toMatchObject({
reasoning_label: 'Inspecting the new direction',
reasoning_label_step_id: 'new-step',
reasoning_label_revision: 2,
reasoning_label_attempts: 2,
});
});
it('does not overwrite another reasoning step at the raw index', () => {
const oldStep = applyReasoningLabel(response(), {
...baseEvent,
stepId: 'old-step',
revision: 3,
label: 'Inspecting the old direction',
status: 'complete',
});
expect(
applyReasoningLabel(oldStep, {
...baseEvent,
stepId: 'new-step',
revision: 1,
label: 'Tracing the regenerated direction',
}),
).toBe(oldStep);
});
it('correlates by step id when an active resume snapshot compacted sparse slots', () => {
const compacted = response();
compacted.content = [
{
type: ContentTypes.THINK,
think: 'Visible reasoning',
reasoning_label_step_id: 'reasoning-1',
},
];
const updated = applyReasoningLabel(compacted, { ...baseEvent, index: 2 });
expect(updated.content?.[0]).toMatchObject({
reasoning_label: 'Tracing resume ownership',
reasoning_label_step_id: 'reasoning-1',
reasoning_label_revision: 1,
});
});
it('targets the exact assistant response id', () => {
const messages = [
{ ...response(), messageId: 'other-response' },
response(),
{ ...response(), messageId: 'user-2', isCreatedByUser: true },
];
expect(findReasoningLabelMessageIndex(messages, baseEvent)).toBe(1);
expect(
findReasoningLabelMessageIndex(messages, {
...baseEvent,
responseMessageId: 'missing-response',
}),
).toBe(-1);
});
});

View file

@ -46,6 +46,7 @@ export * from './steer';
export * from './activityLabels';
export * from './runStepDuration';
export * from './documentTitle';
export * from './reasoningLabels';
export * from './numbers';
export { default as cn } from './cn';
export { default as logger } from './logger';

View file

@ -0,0 +1,107 @@
import { ContentTypes, stripReasoningLabelMetadata } from 'librechat-data-provider';
import type { TMessage, TMessageContentParts, TReasoningLabelEvent } from 'librechat-data-provider';
/** Resolves the assistant response targeted by a reasoning-label update. */
export function findReasoningLabelMessageIndex(
messages: TMessage[],
event: TReasoningLabelEvent,
): number {
const isAssistant = (message: TMessage | undefined) => message?.isCreatedByUser === false;
if (event.responseMessageId) {
return messages.findIndex(
(message) => message.messageId === event.responseMessageId && isAssistant(message),
);
}
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (isAssistant(messages[i])) {
return i;
}
}
return -1;
}
/** Applies a monotonic title revision to an existing THINK part without moving it. */
export function applyReasoningLabel(message: TMessage, event: TReasoningLabelEvent): TMessage {
if (event.index < 0 || !Number.isInteger(event.index)) {
return message;
}
const content = Array.isArray(message.content) ? message.content : [];
if (event.reset === true) {
const existing = content[event.index];
if (
existing?.type !== ContentTypes.THINK ||
existing.reasoning_label_step_id !== event.previousStepId
) {
return message;
}
const attempts = event.attempts ?? existing.reasoning_label_attempts;
const alreadyReset =
existing.reasoning_label_step_id === event.stepId &&
existing.reasoning_label == null &&
existing.reasoning_label_revision == null &&
existing.reasoning_label_status == null &&
existing.reasoning_label_submitted_chars == null &&
existing.reasoning_label_attempts === attempts;
if (alreadyReset) {
return message;
}
const resetPart = stripReasoningLabelMetadata(existing) as typeof existing;
const nextContent = [...content] as TMessageContentParts[];
nextContent[event.index] = {
...resetPart,
reasoning_label_step_id: event.stepId,
...(attempts != null && { reasoning_label_attempts: attempts }),
};
return { ...message, content: nextContent };
}
if (!event.label.trim()) {
return message;
}
let targetIndex = event.index;
let existing = content[targetIndex];
if (
existing?.type !== ContentTypes.THINK ||
(existing.reasoning_label_step_id != null && existing.reasoning_label_step_id !== event.stepId)
) {
targetIndex = content.findIndex(
(part) => part?.type === ContentTypes.THINK && part.reasoning_label_step_id === event.stepId,
);
existing = content[targetIndex];
}
if (existing?.type !== ContentTypes.THINK) {
return message;
}
/** Revisions are scoped to one SDK reasoning step. The stream handler clears
* old metadata when edit/regenerate folds a new step into a retained slot;
* an unowned THINK part therefore starts at revision zero. */
const sameStep = existing.reasoning_label_step_id === event.stepId;
const currentRevision = sameStep ? (existing.reasoning_label_revision ?? 0) : 0;
if (event.revision < currentRevision) {
return message;
}
if (
sameStep &&
event.revision === currentRevision &&
(existing.reasoning_label !== event.label ||
(existing.reasoning_label_status === 'complete' && event.status === 'streaming'))
) {
return message;
}
if (
existing.reasoning_label === event.label &&
existing.reasoning_label_step_id === event.stepId &&
existing.reasoning_label_revision === event.revision &&
existing.reasoning_label_status === event.status
) {
return message;
}
const nextContent = [...content] as TMessageContentParts[];
nextContent[targetIndex] = {
...existing,
reasoning_label: event.label,
reasoning_label_step_id: event.stepId,
reasoning_label_revision: event.revision,
reasoning_label_status: event.status,
};
return { ...message, content: nextContent };
}

View file

@ -472,6 +472,21 @@ endpoints:
# # activityPrompt: 'Write a short activity label...'
# # activityMaxPerRun: 20
# # activityCharLimit: 600
# # (optional) Replace the generic Thinking/Thoughts heading with a live
# # generated orientation as sufficiently long top-level reasoning evolves.
# # Enabling this sends a bounded snapshot (up to 4,000 characters) of the
# # visible reasoning to the resolved label endpoint, which may be a different provider.
# # With Langfuse tracing enabled, that snapshot is also recorded as generation input
# # unless the active redaction policy suppresses the label call.
# # reasoningLabel: true
# # reasoningLabelModel: gpt-4.1-nano # falls back to activity/title/run model
# # reasoningLabelEndpoint: openAI # falls back to activity/run endpoint
# # reasoningLabelPrompt: 'Describe the current reasoning direction...'
# # reasoningLabelMinChars: 500 # text required before the first label
# # reasoningLabelUpdateChars: 400 # new text between streaming revisions
# # reasoningLabelUpdateIntervalMs: 3000 # minimum time between streaming revisions
# # A final rewrite may run immediately after a meaningful 120-character tail.
# # reasoningLabelMaxPerRun: 8 # provider-call cap per response
# # (optional) Maximum total citations to include in agent responses, defaults to 30
# maxCitations: 30
# # (optional) Maximum citations per file to include in agent responses, defaults to 7
@ -636,6 +651,21 @@ endpoints:
# activityPhaseEndpoint: 'anthropic' # falls back to activity/run endpoint
# activityPhasePrompt: 'Summarize the completed agent phase...'
# activityPhaseMaxPerRun: 5 # cost cap per response
# Live reasoning labels are also independent. They update one top-level
# THINK heading in place and never create or shift message content parts.
# Enabling this sends a bounded snapshot (up to 4,000 characters) of the
# visible reasoning to the resolved label endpoint, which may be a different provider.
# With Langfuse tracing enabled, that snapshot is also recorded as generation input
# unless the active redaction policy suppresses the label call.
# reasoningLabel: true
# reasoningLabelModel: 'claude-3-5-haiku' # falls back to activity/title/run model
# reasoningLabelEndpoint: 'anthropic' # falls back to activity/run endpoint
# reasoningLabelPrompt: 'Describe the current reasoning direction...'
# reasoningLabelMinChars: 500
# reasoningLabelUpdateChars: 400
# reasoningLabelUpdateIntervalMs: 3000
# A final rewrite may run immediately after a meaningful 120-character tail.
# reasoningLabelMaxPerRun: 8
modelDisplayLabel: 'Claude (Compatible)'
# Groq Example

10
package-lock.json generated
View file

@ -63,7 +63,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.6.0",
"@librechat/agents": "^3.6.1",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
@ -10628,9 +10628,9 @@
}
},
"node_modules/@librechat/agents": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.6.0.tgz",
"integrity": "sha512-PWDd29NL2MTuomtUl++q8RYozgRXpTY+OA281uws6vssDNTnEcDWLUIsswLPCLIWGh/8DX9vYpxp+biCnKQyZg==",
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.6.1.tgz",
"integrity": "sha512-F8ZPSVJoCbnD78R6aPFfH7oc1sd/HjGkhdHEdKnSPY1UbMC1ajMP3UyZzRVblrmdcjp1kIOpQkJFcgmcChOlSA==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.115.0",
@ -42849,7 +42849,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.6.0",
"@librechat/agents": "^3.6.1",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.30.0",
"@opentelemetry/api": "^1.9.0",

View file

@ -113,7 +113,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.6.0",
"@librechat/agents": "^3.6.1",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.30.0",
"@opentelemetry/api": "^1.9.0",

View file

@ -4,6 +4,7 @@ import {
mapCollectedMetadataToUsage,
resolveActivityConfig,
resolveActivityPhaseConfig,
resolveReasoningLabelConfig,
resolveActivityLabelModel,
} from '../host';
@ -169,6 +170,57 @@ describe('resolveActivityPhaseConfig', () => {
});
});
describe('resolveReasoningLabelConfig', () => {
it('is independently opt-in and inherits activity model and endpoint settings', () => {
const config = resolveReasoningLabelConfig(
appConfig({
openAI: {
activityModel: 'activity-model',
activityEndpoint: 'anthropic',
},
}),
'openAI',
);
expect(config).toMatchObject({
enabled: false,
model: 'activity-model',
endpoint: 'anthropic',
});
});
it('resolves dedicated tuning field-by-field through the public endpoint', () => {
const config = resolveReasoningLabelConfig(
appConfig({
all: { reasoningLabelUpdateIntervalMs: 2_000 },
agents: {
reasoningLabel: true,
reasoningLabelModel: 'reasoning-model',
reasoningLabelPrompt: 'reasoning prompt',
reasoningLabelMinChars: 600,
},
openAI: {
reasoningLabelEndpoint: 'google',
reasoningLabelUpdateChars: 450,
reasoningLabelMaxPerRun: 6,
},
}),
'openAI',
undefined,
'agents',
);
expect(config).toEqual({
enabled: true,
model: 'reasoning-model',
endpoint: 'google',
prompt: 'reasoning prompt',
minChars: 600,
updateChars: 450,
updateIntervalMs: 2_000,
maxPerRun: 6,
});
});
});
describe('resolveActivityLabelModel model precedence', () => {
const db = {} as EndpointDbMethods;
const resolve = (endpointConfig: Record<string, unknown>) =>

View file

@ -11,14 +11,22 @@ import { resolveConfigHeaders } from '~/utils/headers';
import { omitTitleOptions } from '~/agents/client';
import { createSafeUser } from '~/utils/env';
/** Additive phase fields may precede the rebuilt data-provider artifact in a
* package-local typecheck, so keep the endpoint view structural here. */
/** Additive label fields may precede the rebuilt data-provider artifact in a
* package-local typecheck, so keep the endpoint view structural here. */
type ActivityEndpoint = TEndpoint & {
activityPhaseLabel?: boolean;
activityPhaseModel?: string;
activityPhaseEndpoint?: string;
activityPhasePrompt?: string;
activityPhaseMaxPerRun?: number;
reasoningLabel?: boolean;
reasoningLabelModel?: string;
reasoningLabelEndpoint?: string;
reasoningLabelPrompt?: string;
reasoningLabelMinChars?: number;
reasoningLabelUpdateChars?: number;
reasoningLabelUpdateIntervalMs?: number;
reasoningLabelMaxPerRun?: number;
};
/** Cache-token details in the LangChain-standard normalized shape. */
@ -154,6 +162,18 @@ export interface ResolvedActivityPhaseConfig {
charLimit?: number;
}
/** Effective live reasoning-label settings for one endpoint. */
export interface ResolvedReasoningLabelConfig {
enabled: boolean;
model?: string;
endpoint?: string;
prompt?: string;
minChars?: number;
updateChars?: number;
updateIntervalMs?: number;
maxPerRun?: number;
}
/**
* Reads the per-endpoint `activity*` settings, mirroring how titles resolve
* theirs: an `endpoints.all` block wins over the named endpoint, which wins
@ -234,6 +254,26 @@ export function resolveActivityPhaseConfig(
};
}
export function resolveReasoningLabelConfig(
appConfig: AppConfig | undefined,
endpoint: string,
customEndpointConfig?: Partial<TEndpoint>,
publicEndpoint?: string,
): ResolvedReasoningLabelConfig {
const pick = <K extends keyof ActivityEndpoint>(key: K): ActivityEndpoint[K] | undefined =>
pickEndpointField(appConfig, endpoint, customEndpointConfig, key, publicEndpoint);
return {
enabled: pick('reasoningLabel') === true,
model: pick('reasoningLabelModel') ?? pick('activityModel'),
endpoint: pick('reasoningLabelEndpoint') ?? pick('activityEndpoint'),
prompt: pick('reasoningLabelPrompt'),
minChars: pick('reasoningLabelMinChars'),
updateChars: pick('reasoningLabelUpdateChars'),
updateIntervalMs: pick('reasoningLabelUpdateIntervalMs'),
maxPerRun: pick('reasoningLabelMaxPerRun'),
};
}
/**
* Resolves provider + client options for the label model, mirroring
* `titleConvo`'s resolution. Model precedence: the endpoint's
@ -249,23 +289,37 @@ export async function resolveActivityLabelModel({
ids,
db,
phase = false,
}: ResolveActivityLabelModelParams & { phase?: boolean }): Promise<ActivityLabelLLM> {
reasoning = false,
}: ResolveActivityLabelModelParams & {
phase?: boolean;
reasoning?: boolean;
}): Promise<ActivityLabelLLM> {
const appConfig = req.config as AppConfig | undefined;
const agentEndpoint = agent.endpoint ?? '';
let providerConfig = getProviderConfig({ provider: agentEndpoint, appConfig });
const activity = phase
? resolveActivityPhaseConfig(
appConfig,
agentEndpoint,
providerConfig.customEndpointConfig,
publicEndpoint,
)
: resolveActivityConfig(
appConfig,
agentEndpoint,
providerConfig.customEndpointConfig,
publicEndpoint,
);
let activity: ResolvedActivityConfig | ResolvedActivityPhaseConfig | ResolvedReasoningLabelConfig;
if (reasoning) {
activity = resolveReasoningLabelConfig(
appConfig,
agentEndpoint,
providerConfig.customEndpointConfig,
publicEndpoint,
);
} else if (phase) {
activity = resolveActivityPhaseConfig(
appConfig,
agentEndpoint,
providerConfig.customEndpointConfig,
publicEndpoint,
);
} else {
activity = resolveActivityConfig(
appConfig,
agentEndpoint,
providerConfig.customEndpointConfig,
publicEndpoint,
);
}
/**
* Captured from the ORIGINATING endpoint, before any `activityEndpoint`
@ -289,8 +343,14 @@ export async function resolveActivityLabelModel({
providerConfig = getProviderConfig({ provider: activity.endpoint, appConfig });
endpoint = activity.endpoint;
} catch (error) {
let endpointField = 'activityEndpoint';
if (reasoning) {
endpointField = 'reasoningLabelEndpoint';
} else if (phase) {
endpointField = 'activityPhaseEndpoint';
}
logger.warn(
`[activityLabels] Unknown activityEndpoint "${activity.endpoint}", falling back to "${agentEndpoint}"`,
`[activityLabels] Unknown ${endpointField} "${activity.endpoint}", falling back to "${agentEndpoint}"`,
error,
);
providerConfig = getProviderConfig({ provider: agentEndpoint, appConfig });
@ -440,6 +500,13 @@ export function resolveActivityPhaseLabelModel(
return resolveActivityLabelModel({ ...params, phase: true });
}
/** Reasoning-label model resolution shares credentials and sanitization with activity labels. */
export function resolveReasoningLabelModel(
params: ResolveActivityLabelModelParams,
): Promise<ActivityLabelLLM> {
return resolveActivityLabelModel({ ...params, reasoning: true });
}
/**
* Bounded wait for in-flight label fills so a label resolving during the
* final batch still reaches the durable log and the saved message before the

View file

@ -25,14 +25,17 @@ export {
mapCollectedMetadataToUsage,
resolveActivityConfig,
resolveActivityPhaseConfig,
resolveReasoningLabelConfig,
resolveActivityLabelModel,
resolveActivityPhaseLabelModel,
resolveReasoningLabelModel,
settlePendingLabelFills,
} from './host';
export type {
ActivityLabelAgent,
ResolvedActivityConfig,
ResolvedActivityPhaseConfig,
ResolvedReasoningLabelConfig,
ActivityLabelUsage,
CollectedMetadataEntry,
ResolveActivityLabelModelParams,

View file

@ -44,4 +44,5 @@ export * from './hooks';
export * from './steering';
export * from './activityLabels';
export * from './activityPhases';
export * from './reasoningLabels';
export * from './toolValidation';

View file

@ -0,0 +1,69 @@
import { ContentTypes } from 'librechat-data-provider';
import {
createReasoningLabelHostWiring,
getLabelUsageSequenceSeed,
type CreateReasoningLabelHostWiringOptions,
} from './host';
function createHost(
overrides: Partial<CreateReasoningLabelHostWiringOptions> = {},
): ReturnType<typeof createReasoningLabelHostWiring> {
return createReasoningLabelHostWiring({
config: { enabled: true },
getContentParts: () => [],
getStepIndex: () => undefined,
emitEvent: async () => undefined,
trackPendingFill: () => undefined,
generateLabel: async () => ({}),
...overrides,
});
}
describe('reasoning label host', () => {
it('seeds shared usage from the highest run-global reasoning high-water', () => {
const parts = [
{ type: ContentTypes.ACTIVITY_LABEL },
{ type: ContentTypes.ACTIVITY_LABEL },
...[1, 2, 3, 4].map((reasoning_label_revision) => ({
type: ContentTypes.THINK,
reasoning_label_revision,
})),
];
expect(getLabelUsageSequenceSeed(parts)).toBe(6);
expect(
getLabelUsageSequenceSeed([
...parts,
{ type: ContentTypes.THINK, reasoning_label_attempts: 7 },
]),
).toBe(9);
expect(getLabelUsageSequenceSeed(parts, 12)).toBe(12);
});
it('owns the abort scope around the runtime wiring', () => {
const abort = new AbortController();
const host = createHost({ abortSignal: abort.signal });
expect(host.scope.closed).toBe(false);
expect(host.scope.abort.signal.aborted).toBe(false);
abort.abort();
expect(host.scope.closed).toBe(true);
expect(host.scope.abort.signal.aborted).toBe(true);
});
it('retries the resumable marker once without rejecting the run', async () => {
const markResumable = jest
.fn<Promise<void>, []>()
.mockRejectedValueOnce(new Error('transient marker failure'))
.mockResolvedValueOnce(undefined);
const onMarkFailure = jest.fn();
const host = createHost({ markResumable, onMarkFailure });
await host.markedPromise;
expect(markResumable).toHaveBeenCalledTimes(2);
expect(onMarkFailure).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,345 @@
import { createMetadataAggregator } from '@librechat/agents';
import { ContentTypes, ReasoningLabelEvents } from 'librechat-data-provider';
import type { HandleLLMEnd, Providers } from '@librechat/agents';
import type {
GeneratedReasoningLabel,
GenerateReasoningLabelPayload,
ReasoningLabelAttemptEvent,
ReasoningLabelEvent,
ReasoningLabelHostDeps,
ReasoningLabelStatus,
ReasoningLabelWiring,
} from './runtime';
import type { ResolvedReasoningLabelConfig } from '~/agents/activityLabels/host';
import type { ActivityLabelLLM } from '~/agents/activityLabels/runtime';
import type { LooseContentPart } from '~/agents/activityLabels/wiring';
import { createReasoningLabelWiring } from './runtime';
type ReasoningLabelUsageMetadata = Record<string, unknown>;
type ReasoningLabelLLMOutput = Parameters<HandleLLMEnd>[0];
interface ReasoningLabelPromptMessage {
content: unknown;
}
interface ReasoningLabelCaptureCallback {
handleLLMStart: (_llm: unknown, prompts: string[]) => void;
handleChatModelStart: (_llm: unknown, messages: ReasoningLabelPromptMessage[][]) => void;
handleLLMEnd: HandleLLMEnd;
}
interface ReasoningLabelSDKOptions {
provider: Providers;
clientOptions: ActivityLabelLLM['clientOptions'];
visibleReasoning: string;
reasoningStepId: string;
revision: number;
status: ReasoningLabelStatus;
previousLabel?: string;
agentId?: string;
charLimit: number;
prompt?: string;
sourceRunId: string;
sourceTraceId: string;
responseId: string;
chainOptions: {
signal: AbortSignal;
callbacks: ReasoningLabelCaptureCallback[];
configurable: {
thread_id: string;
user_id?: string;
requestBody: { parentMessageId?: string };
};
};
}
export interface ReasoningLabelRun {
generateReasoningLabel: (
options: ReasoningLabelSDKOptions,
) => Promise<{ label?: string; usage?: ReasoningLabelUsageMetadata }>;
}
export interface ReasoningLabelUsageRecord {
collectedMetadata: Record<string, unknown>[];
model?: string;
endpointTokenConfig?: unknown;
sameEndpoint?: boolean;
provider: Providers;
promptText: string;
completionText: string;
}
export interface GenerateReasoningLabelRevisionOptions {
payload: GenerateReasoningLabelPayload;
run?: ReasoningLabelRun;
resolveModel: () => Promise<ActivityLabelLLM>;
sourceRunId: string;
sourceTraceId: string;
responseId: string;
sessionId: string;
userId?: string;
parentMessageId?: string;
recordUsage: (usage: ReasoningLabelUsageRecord) => void | Promise<void>;
onError?: (error: unknown) => void;
}
export interface ReasoningLabelHostScope {
closed: boolean;
abort: AbortController;
detach?: () => void;
}
type ReasoningLabelDurableEvent = ReasoningLabelAttemptEvent | ReasoningLabelEvent;
export interface CreateReasoningLabelHostWiringOptions {
config: ResolvedReasoningLabelConfig;
seedFromContent?: boolean;
abortSignal?: AbortSignal;
markResumable?: () => Promise<unknown>;
onMarkFailure?: () => void;
getContentParts: ReasoningLabelHostDeps['getContentParts'];
getStepIndex: ReasoningLabelHostDeps['getStepIndex'];
emitEvent: (event: ReasoningLabelEvents, data: ReasoningLabelDurableEvent) => Promise<unknown>;
trackPendingFill: ReasoningLabelHostDeps['trackPendingFill'];
generateLabel: ReasoningLabelHostDeps['generateLabel'];
}
export interface ReasoningLabelHostWiringResult {
wiring: ReasoningLabelWiring;
scope: ReasoningLabelHostScope;
markedPromise?: Promise<void>;
}
/** Computes the shared negative usage sequence from durable activity and reasoning calls. */
export function getLabelUsageSequenceSeed(
parts: ReadonlyArray<LooseContentPart | null | undefined>,
current = 0,
): number {
let activityLabels = 0;
let reasoningAttempts = 0;
let reasoningCommittedFallback = 0;
for (const part of parts) {
if (part?.type === ContentTypes.ACTIVITY_LABEL) {
activityLabels += 1;
continue;
}
if (part?.type !== ContentTypes.THINK) {
continue;
}
if (typeof part.reasoning_label_attempts === 'number') {
reasoningAttempts = Math.max(reasoningAttempts, part.reasoning_label_attempts);
}
if (typeof part.reasoning_label_revision === 'number' && part.reasoning_label_revision > 0) {
reasoningCommittedFallback = Math.max(
reasoningCommittedFallback,
part.reasoning_label_revision,
);
}
}
return Math.max(
current,
activityLabels + Math.max(reasoningAttempts, reasoningCommittedFallback),
);
}
async function markResumableWithRetry(
markResumable: () => Promise<unknown>,
onMarkFailure?: () => void,
): Promise<void> {
try {
await markResumable();
} catch {
try {
await markResumable();
} catch {
onMarkFailure?.();
}
}
}
/** Owns the host-side lifecycle around the pure reasoning-label stream controller. */
export function createReasoningLabelHostWiring({
config,
seedFromContent = false,
abortSignal,
markResumable,
onMarkFailure,
getContentParts,
getStepIndex,
emitEvent,
trackPendingFill,
generateLabel,
}: CreateReasoningLabelHostWiringOptions): ReasoningLabelHostWiringResult {
const scope: ReasoningLabelHostScope = { closed: false, abort: new AbortController() };
const closeOnAbort = () => {
scope.closed = true;
scope.abort.abort();
};
if (abortSignal != null) {
if (abortSignal.aborted) {
closeOnAbort();
} else {
abortSignal.addEventListener('abort', closeOnAbort, { once: true });
scope.detach = () => abortSignal.removeEventListener('abort', closeOnAbort);
}
}
const wiring = createReasoningLabelWiring({
minChars: config.minChars,
updateChars: config.updateChars,
updateIntervalMs: config.updateIntervalMs,
maxPerRun: config.maxPerRun,
...(!seedFromContent && { initialAttempts: 0 }),
prompt: config.prompt,
abortSignal: scope.abort.signal,
isClosed: () => scope.closed,
getContentParts,
getStepIndex,
emitAttemptEvent: (event) => emitEvent(ReasoningLabelEvents.ON_REASONING_LABEL_ATTEMPT, event),
emitLabelEvent: (event) => emitEvent(ReasoningLabelEvents.ON_REASONING_LABEL, event),
trackPendingFill,
generateLabel,
});
return {
wiring,
scope,
...(markResumable != null && {
markedPromise: markResumableWithRetry(markResumable, onMarkFailure),
}),
};
}
function extractLLMCompletionText(output: ReasoningLabelLLMOutput): string | undefined {
const generations = output.generations;
const generation = generations[generations.length - 1]?.[0] as
| { message?: { content?: unknown }; text?: unknown }
| undefined;
const content = generation?.message?.content;
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content
.map((block) => {
if (typeof block === 'string') {
return block;
}
const text = (block as { text?: unknown } | null)?.text;
return typeof text === 'string' ? text : '';
})
.join('');
}
return typeof generation?.text === 'string' ? generation.text : undefined;
}
function serializeChatPrompt(messages: ReasoningLabelPromptMessage[][]): string {
const content: string[] = [];
for (const batch of messages) {
for (const message of batch) {
content.push(
typeof message.content === 'string'
? message.content
: (JSON.stringify(message.content ?? '') ?? ''),
);
}
}
return content.join('\n');
}
/** Invokes one SDK reasoning-label revision and captures complete provider text for billing. */
export async function generateReasoningLabelRevision({
payload,
run,
resolveModel,
sourceRunId,
sourceTraceId,
responseId,
sessionId,
userId,
parentMessageId,
recordUsage,
onError,
}: GenerateReasoningLabelRevisionOptions): Promise<GeneratedReasoningLabel> {
if (typeof run?.generateReasoningLabel !== 'function') {
return {};
}
const { provider, clientOptions, endpointTokenConfig, sameEndpoint } = await resolveModel();
const { handleLLMEnd, collected } = createMetadataAggregator();
let sdkPromptText: string | undefined;
let sdkCompletionText: string | undefined;
const capturePrompt: ReasoningLabelCaptureCallback = {
handleLLMStart: (_llm: unknown, prompts: string[]) => {
sdkPromptText = Array.isArray(prompts) ? prompts.join('\n') : undefined;
},
handleChatModelStart: (_llm: unknown, messages: ReasoningLabelPromptMessage[][]) => {
try {
sdkPromptText = serializeChatPrompt(messages ?? []);
} catch {
// Providers with usage metadata do not need the estimate fallback.
}
},
handleLLMEnd: (output, runId, parentRunId, tags) => {
const completionText = extractLLMCompletionText(output);
if (completionText != null) {
sdkCompletionText = completionText;
}
handleLLMEnd(output, runId, parentRunId, tags);
},
};
let label: string | undefined;
let usage: ReasoningLabelUsageMetadata | undefined;
let completed = false;
try {
({ label, usage } = await run.generateReasoningLabel({
provider,
clientOptions,
visibleReasoning: payload.visibleReasoning,
reasoningStepId: payload.reasoningStepId,
revision: payload.revision,
status: payload.status,
...(payload.previousLabel != null && { previousLabel: payload.previousLabel }),
...(payload.agentId != null && { agentId: payload.agentId }),
charLimit: payload.charLimit,
...(payload.prompt != null && { prompt: payload.prompt }),
sourceRunId,
sourceTraceId,
responseId,
chainOptions: {
signal: payload.signal,
callbacks: [capturePrompt],
configurable: {
thread_id: sessionId,
...(userId != null && { user_id: userId }),
requestBody: { ...(parentMessageId != null && { parentMessageId }) },
},
},
}));
completed = true;
} catch (error) {
if (!payload.signal.aborted) {
onError?.(error);
}
}
const collectedMetadata = usage != null ? [{ usage_metadata: usage }] : collected;
const shouldCollectUsage =
usage != null ||
collected.length > 0 ||
(completed && (label != null || sdkPromptText != null || sdkCompletionText != null));
return {
label,
...(shouldCollectUsage && {
collectUsage: (completionText?: string) =>
recordUsage({
collectedMetadata,
model: clientOptions.model,
endpointTokenConfig,
sameEndpoint,
provider,
promptText: sdkPromptText ?? '',
completionText: sdkCompletionText ?? completionText ?? '',
}),
}),
};
}

View file

@ -0,0 +1,23 @@
export { createReasoningLabelWiring, synthesizeReasoningLabelGapEvents } from './runtime';
export {
createReasoningLabelHostWiring,
generateReasoningLabelRevision,
getLabelUsageSequenceSeed,
} from './host';
export type {
GeneratedReasoningLabel,
GenerateReasoningLabelPayload,
ReasoningLabelAttemptEvent,
ReasoningLabelEvent,
ReasoningLabelHostDeps,
ReasoningLabelStatus,
ReasoningLabelWiring,
} from './runtime';
export type {
CreateReasoningLabelHostWiringOptions,
GenerateReasoningLabelRevisionOptions,
ReasoningLabelHostScope,
ReasoningLabelHostWiringResult,
ReasoningLabelRun,
ReasoningLabelUsageRecord,
} from './host';

View file

@ -0,0 +1,875 @@
import { GraphEvents } from '@librechat/agents';
import { ContentTypes, StepTypes } from 'librechat-data-provider';
import type { EventHandler } from '@librechat/agents';
import type {
GeneratedReasoningLabel,
ReasoningLabelAttemptEvent,
ReasoningLabelEvent,
} from './runtime';
import type { LooseContentPart } from '~/agents/activityLabels/wiring';
import { createReasoningLabelWiring, synthesizeReasoningLabelGapEvents } from './runtime';
function reasoningDelta(id: string, think: string) {
return { id, delta: { content: { type: ContentTypes.THINK, think } } };
}
function createHarness(
generateLabel: (payload: {
visibleReasoning: string;
revision: number;
status: 'streaming' | 'complete';
previousLabel?: string;
}) => Promise<GeneratedReasoningLabel>,
config: {
minChars?: number;
updateChars?: number;
updateIntervalMs?: number;
maxPerRun?: number;
initialAttempts?: number;
initialParts?: LooseContentPart[];
preservePartOnStart?: boolean;
emitAttemptEvent?: (event: ReasoningLabelAttemptEvent) => Promise<void>;
emitLabelEvent?: (event: ReasoningLabelEvent) => Promise<void>;
} = {},
) {
const { initialParts, preservePartOnStart, emitAttemptEvent, emitLabelEvent, ...wiringConfig } =
config;
const parts: LooseContentPart[] = [...(initialParts ?? [])];
const stepIndices = new Map<string, number>();
const attemptEvents: ReasoningLabelAttemptEvent[] = [];
const events: ReasoningLabelEvent[] = [];
const pending: Promise<void>[] = [];
const handlers: Record<string, EventHandler> = {
[GraphEvents.ON_RUN_STEP]: {
handle: (_event, data) => {
const step = data as { id: string; index: number };
stepIndices.set(step.id, step.index);
if (!(preservePartOnStart && parts[step.index]?.type === ContentTypes.THINK)) {
parts[step.index] = { type: ContentTypes.THINK, think: '' };
}
},
},
[GraphEvents.ON_REASONING_DELTA]: {
handle: (_event, data) => {
const delta = data as ReturnType<typeof reasoningDelta>;
const index = stepIndices.get(delta.id);
const part = index != null ? parts[index] : undefined;
if (index != null && part?.type === ContentTypes.THINK) {
parts[index] = {
type: ContentTypes.THINK,
think: `${typeof part.think === 'string' ? part.think : ''}${delta.delta.content.think}`,
};
}
},
},
[GraphEvents.ON_MESSAGE_DELTA]: { handle: () => undefined },
[GraphEvents.ON_RUN_STEP_CLOSED]: { handle: () => undefined },
};
const wiring = createReasoningLabelWiring({
...wiringConfig,
getContentParts: () => parts,
getStepIndex: (stepId) => stepIndices.get(stepId),
emitAttemptEvent: async (event) => {
await emitAttemptEvent?.(event);
attemptEvents.push(event);
},
emitLabelEvent: async (event) => {
await emitLabelEvent?.(event);
events.push(event);
},
trackPendingFill: (task) => pending.push(task),
generateLabel,
});
const wrapped = wiring.handlers(handlers)!;
const start = async (id = 'reasoning-1', index = 0, metadata?: Record<string, unknown>) => {
await wrapped[GraphEvents.ON_RUN_STEP].handle(
GraphEvents.ON_RUN_STEP,
{
id,
index,
stepDetails: {
type: StepTypes.MESSAGE_CREATION,
message_creation: { content_type: ContentTypes.THINK },
},
},
metadata,
);
};
const append = async (text: string, id = 'reasoning-1') => {
await wrapped[GraphEvents.ON_REASONING_DELTA].handle(
GraphEvents.ON_REASONING_DELTA,
reasoningDelta(id, text),
);
};
const close = async (id = 'reasoning-1') => {
await wrapped[GraphEvents.ON_RUN_STEP_CLOSED].handle(GraphEvents.ON_RUN_STEP_CLOSED, {
id,
status: 'completed',
});
};
const settle = async () => {
for (let i = 0; i < 5; i += 1) {
const tasks = pending.splice(0);
if (tasks.length === 0) {
await Promise.resolve();
if (pending.length === 0) {
return;
}
}
await Promise.allSettled(tasks);
}
};
return { append, attemptEvents, close, events, parts, settle, start, wiring };
}
describe('reasoning labels', () => {
it('generates the first label only after the minimum visible length', async () => {
const generate = jest.fn(async () => ({ label: 'Tracing resume ownership' }));
const harness = createHarness(generate, { minChars: 10, updateChars: 8, updateIntervalMs: 0 });
await harness.start();
await harness.append('123456789');
expect(generate).not.toHaveBeenCalled();
await harness.append('0');
await harness.settle();
expect(generate).toHaveBeenCalledTimes(1);
expect(generate).toHaveBeenCalledWith(
expect.objectContaining({ revision: 1, status: 'streaming', visibleReasoning: '1234567890' }),
);
expect(harness.parts).toHaveLength(1);
expect(harness.parts[0]).toMatchObject({
reasoning_label: 'Tracing resume ownership',
reasoning_label_revision: 1,
reasoning_label_status: 'streaming',
});
});
it('waits for new reasoning when a new step reuses a THINK slot', async () => {
const generate = jest.fn(async () => ({ label: 'Inspecting the new reasoning' }));
const harness = createHarness(generate, {
minChars: 5,
updateChars: 4,
updateIntervalMs: 0,
preservePartOnStart: true,
initialParts: [
{
type: ContentTypes.THINK,
think: 'stale reasoning from the prior step',
reasoning_label: 'Inspecting the prior reasoning',
reasoning_label_step_id: 'reasoning-old',
reasoning_label_revision: 1,
reasoning_label_status: 'complete',
},
],
});
await harness.start('reasoning-new', 0);
await harness.settle();
expect(generate).not.toHaveBeenCalled();
await harness.append('1234', 'reasoning-new');
await harness.settle();
expect(generate).not.toHaveBeenCalled();
await harness.append('5', 'reasoning-new');
await harness.settle();
expect(generate).toHaveBeenCalledTimes(1);
expect(generate).toHaveBeenCalledWith(
expect.objectContaining({ visibleReasoning: '12345', revision: 2 }),
);
});
it('requires both changed characters and the minimum interval for revisions', async () => {
jest.useFakeTimers();
try {
jest.setSystemTime(1_000);
const generate = jest
.fn()
.mockResolvedValueOnce({ label: 'Inspecting the stream' })
.mockResolvedValueOnce({ label: 'Tracing the stream race' });
const harness = createHarness(generate, {
minChars: 5,
updateChars: 4,
updateIntervalMs: 3_000,
});
await harness.start();
await harness.append('12345');
await harness.settle();
await harness.append('6789');
expect(generate).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(3_000);
await harness.settle();
expect(generate).toHaveBeenCalledTimes(2);
expect(generate.mock.calls[1][0]).toMatchObject({ revision: 2 });
expect(harness.events[harness.events.length - 1]).toMatchObject({
revision: 2,
label: 'Tracing the stream race',
});
} finally {
jest.useRealTimers();
}
});
it('enforces the update interval after an empty first attempt', async () => {
jest.useFakeTimers();
try {
jest.setSystemTime(1_000);
const generate = jest.fn(async () => ({}));
const harness = createHarness(generate, {
minChars: 5,
updateChars: 4,
updateIntervalMs: 3_000,
});
await harness.start();
await harness.append('12345');
await harness.settle();
await harness.append('6789');
expect(generate).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(3_000);
await harness.settle();
expect(generate).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
});
it('collects provider usage even when an attempt produces no visible label', async () => {
const collectUsage = jest.fn(async () => undefined);
const harness = createHarness(async () => ({ collectUsage }), {
minChars: 5,
updateChars: 100,
updateIntervalMs: 0,
});
await harness.start();
await harness.append('12345');
await harness.settle();
expect(collectUsage).toHaveBeenCalledTimes(1);
expect(collectUsage).toHaveBeenCalledWith(undefined);
expect(harness.events).toHaveLength(0);
});
it('collects provider usage before a durable label patch fails', async () => {
const collectUsage = jest.fn(async () => undefined);
const harness = createHarness(async () => ({ label: 'Inspecting the stream', collectUsage }), {
minChars: 5,
updateChars: 100,
updateIntervalMs: 0,
emitLabelEvent: async () => {
throw new Error('durable emit failed');
},
});
await harness.start();
await harness.append('12345');
await harness.settle();
expect(collectUsage).toHaveBeenCalledTimes(1);
expect(harness.parts[0]).not.toHaveProperty('reasoning_label');
});
it('does not delay a durable visible label while usage persistence is pending', async () => {
let releaseUsage: (() => void) | undefined;
let markUsageStarted: (() => void) | undefined;
let markEmitStarted: (() => void) | undefined;
const usageStarted = new Promise<void>((resolve) => {
markUsageStarted = resolve;
});
const emitStarted = new Promise<void>((resolve) => {
markEmitStarted = resolve;
});
const usageGate = new Promise<void>((resolve) => {
releaseUsage = resolve;
});
const harness = createHarness(
async () => ({
label: 'Inspecting the stream',
collectUsage: async () => {
markUsageStarted?.();
await usageGate;
},
}),
{
minChars: 5,
updateChars: 100,
updateIntervalMs: 0,
emitLabelEvent: async () => {
markEmitStarted?.();
},
},
);
await harness.start();
await harness.append('12345');
await usageStarted;
await emitStarted;
await Promise.resolve();
await Promise.resolve();
expect(harness.events).toHaveLength(1);
expect(harness.parts[0]).toMatchObject({
reasoning_label: 'Inspecting the stream',
reasoning_label_revision: 1,
});
releaseUsage?.();
await harness.settle();
});
it('does not block a terminal revision on usage persistence from the streaming revision', async () => {
let releaseUsage: (() => void) | undefined;
let markStreamingEmit: (() => void) | undefined;
let markTerminalEmit: (() => void) | undefined;
const usageGate = new Promise<void>((resolve) => {
releaseUsage = resolve;
});
const streamingEmit = new Promise<void>((resolve) => {
markStreamingEmit = resolve;
});
const terminalEmit = new Promise<void>((resolve) => {
markTerminalEmit = resolve;
});
const generate = jest
.fn()
.mockResolvedValueOnce({
label: 'Inspecting the stream',
collectUsage: async () => usageGate,
})
.mockResolvedValueOnce({
label: 'Validated the terminal result',
collectUsage: async () => undefined,
});
const harness = createHarness(generate, {
minChars: 5,
updateChars: 400,
updateIntervalMs: 3_000,
emitLabelEvent: async ({ status }) => {
if (status === 'complete') {
markTerminalEmit?.();
} else {
markStreamingEmit?.();
}
},
});
await harness.start();
await harness.append('12345');
await streamingEmit;
await harness.append('x'.repeat(120));
await harness.close();
await terminalEmit;
await new Promise((resolve) => setImmediate(resolve));
expect(generate).toHaveBeenCalledTimes(2);
expect(generate.mock.calls[1][0]).toMatchObject({ revision: 2, status: 'complete' });
expect(harness.events[harness.events.length - 1]).toMatchObject({
revision: 2,
label: 'Validated the terminal result',
status: 'complete',
});
releaseUsage?.();
await harness.settle();
});
it('restamps a committed label after later reasoning deltas rebuild the THINK part', async () => {
const generate = jest.fn(async () => ({ label: 'Tracing the streaming path' }));
const harness = createHarness(generate, { minChars: 5, updateChars: 100, updateIntervalMs: 0 });
await harness.start();
await harness.append('12345');
await harness.settle();
await harness.append(' later');
expect(harness.parts[0]).toMatchObject({
think: '12345 later',
reasoning_label: 'Tracing the streaming path',
reasoning_label_step_id: 'reasoning-1',
reasoning_label_revision: 1,
reasoning_label_status: 'streaming',
});
});
it('patches the authoritative THINK part when a delta replaces it during durable emit', async () => {
let releaseEmit: (() => void) | undefined;
let markEmitStarted: (() => void) | undefined;
const emitStarted = new Promise<void>((resolve) => {
markEmitStarted = resolve;
});
const emitGate = new Promise<void>((resolve) => {
releaseEmit = resolve;
});
const harness = createHarness(async () => ({ label: 'Tracing the concurrent stream' }), {
minChars: 5,
updateChars: 100,
updateIntervalMs: 0,
emitLabelEvent: async () => {
markEmitStarted?.();
await emitGate;
},
});
await harness.start();
await harness.append('12345');
await emitStarted;
await harness.append(' later');
releaseEmit?.();
await harness.settle();
expect(harness.parts[0]).toMatchObject({
think: '12345 later',
reasoning_label: 'Tracing the concurrent stream',
reasoning_label_revision: 1,
});
});
it('does not patch a label after another reasoning step reuses its THINK slot', async () => {
let releaseEmit: (() => void) | undefined;
let markEmitStarted: (() => void) | undefined;
const emitStarted = new Promise<void>((resolve) => {
markEmitStarted = resolve;
});
const emitGate = new Promise<void>((resolve) => {
releaseEmit = resolve;
});
const harness = createHarness(async () => ({ label: 'Inspecting the old step' }), {
minChars: 5,
updateChars: 100,
updateIntervalMs: 0,
emitLabelEvent: async () => {
markEmitStarted?.();
await emitGate;
},
});
await harness.start('reasoning-1', 0);
await harness.append('12345', 'reasoning-1');
await emitStarted;
await harness.start('reasoning-2', 0);
releaseEmit?.();
await harness.settle();
expect(harness.parts[0]).toMatchObject({
type: ContentTypes.THINK,
reasoning_label_step_id: 'reasoning-2',
});
expect(harness.parts[0]).not.toHaveProperty('reasoning_label');
});
it('does not invoke the model after an attempt reservation loses step ownership', async () => {
let releaseEmit: (() => void) | undefined;
let markEmitStarted: (() => void) | undefined;
const emitStarted = new Promise<void>((resolve) => {
markEmitStarted = resolve;
});
const emitGate = new Promise<void>((resolve) => {
releaseEmit = resolve;
});
const generate = jest.fn(async () => ({ label: 'Inspecting the old step' }));
const harness = createHarness(generate, {
minChars: 5,
updateChars: 100,
updateIntervalMs: 0,
emitAttemptEvent: async () => {
markEmitStarted?.();
await emitGate;
},
});
await harness.start('reasoning-1', 0);
await harness.append('12345', 'reasoning-1');
await emitStarted;
await harness.start('reasoning-2', 0);
releaseEmit?.();
await harness.settle();
expect(generate).not.toHaveBeenCalled();
expect(harness.parts[0]).toMatchObject({
type: ContentTypes.THINK,
reasoning_label_step_id: 'reasoning-2',
});
});
it('queues one trailing final revision while a generation is in flight', async () => {
let resolveFirst: ((value: GeneratedReasoningLabel) => void) | undefined;
const generate = jest
.fn()
.mockImplementationOnce(
() =>
new Promise<GeneratedReasoningLabel>((resolve) => {
resolveFirst = resolve;
}),
)
.mockResolvedValueOnce({ label: 'Resolved the final direction' });
const harness = createHarness(generate, { minChars: 5, updateChars: 4, updateIntervalMs: 0 });
await harness.start();
await harness.append('12345');
await harness.append('6789');
await harness.close();
expect(generate).toHaveBeenCalledTimes(1);
resolveFirst?.({ label: 'Investigating the direction' });
await harness.settle();
expect(generate).toHaveBeenCalledTimes(2);
expect(generate.mock.calls[1][0]).toMatchObject({
revision: 2,
status: 'complete',
previousLabel: 'Investigating the direction',
visibleReasoning: '123456789',
});
expect(harness.events[harness.events.length - 1]).toMatchObject({
revision: 2,
status: 'complete',
label: 'Resolved the final direction',
});
});
it('marks the last committed label complete without paying for a trivial tail', async () => {
const generate = jest.fn(async () => ({ label: 'Inspecting the stream' }));
const harness = createHarness(generate, { minChars: 5, updateChars: 400, updateIntervalMs: 0 });
await harness.start();
await harness.append('12345');
await harness.settle();
await harness.append('small tail');
await harness.close();
await harness.settle();
expect(generate).toHaveBeenCalledTimes(1);
expect(harness.events[harness.events.length - 1]).toMatchObject({
revision: 1,
status: 'complete',
});
});
it('rewrites a meaningful 120-character final tail inside the streaming revision gates', async () => {
const generate = jest
.fn()
.mockResolvedValueOnce({ label: 'Inspecting the stream' })
.mockResolvedValueOnce({ label: 'Resolved the stream direction' });
const harness = createHarness(generate, {
minChars: 5,
updateChars: 400,
updateIntervalMs: 3_000,
});
await harness.start();
await harness.append('12345');
await harness.settle();
await harness.append('x'.repeat(120));
await harness.close();
await harness.settle();
expect(generate).toHaveBeenCalledTimes(2);
expect(generate.mock.calls[1][0]).toMatchObject({ revision: 2, status: 'complete' });
expect(harness.events[harness.events.length - 1]).toMatchObject({
revision: 2,
status: 'complete',
label: 'Resolved the stream direction',
});
});
it('does not label reasoning hidden by sequential-output visibility', async () => {
const generate = jest.fn(async () => ({ label: 'Inspecting a hidden step' }));
const harness = createHarness(generate, { minChars: 5, updateChars: 4, updateIntervalMs: 0 });
await harness.start('reasoning-1', 0, {
hide_sequential_outputs: true,
last_agent_id: 'final-agent',
langgraph_node: 'intermediate-agent',
});
await harness.append('12345');
await harness.close();
await harness.settle();
expect(generate).not.toHaveBeenCalled();
expect(harness.events).toHaveLength(0);
});
it('seeds the call cap from durable attempts across a resumed segment', async () => {
const generate = jest.fn(async () => ({}));
const firstSegment = createHarness(generate, {
minChars: 5,
updateChars: 4,
updateIntervalMs: 0,
maxPerRun: 2,
});
await firstSegment.start('reasoning-1', 0);
await firstSegment.append('12345');
await firstSegment.settle();
await firstSegment.append('6789');
await firstSegment.settle();
expect(generate).toHaveBeenCalledTimes(2);
expect(firstSegment.parts[0]).toMatchObject({
reasoning_label_step_id: 'reasoning-1',
reasoning_label_attempts: 2,
reasoning_label_submitted_chars: 9,
});
const resumedGenerate = jest.fn(async () => ({ label: 'Should not run' }));
const resumed = createHarness(resumedGenerate, {
minChars: 5,
updateChars: 4,
updateIntervalMs: 0,
maxPerRun: 2,
initialParts: firstSegment.parts,
});
await resumed.start('reasoning-2', 1);
await resumed.append('abcde', 'reasoning-2');
await resumed.close('reasoning-2');
await resumed.settle();
expect(resumedGenerate).not.toHaveBeenCalled();
});
it('uses the highest committed revision as the legacy resume fallback', async () => {
const generate = jest.fn(async () => ({ label: 'Continued the resumed investigation' }));
const initialParts: LooseContentPart[] = [1, 2, 3, 4].map((revision) => ({
type: ContentTypes.THINK,
think: `prior reasoning ${revision}`,
reasoning_label: `Prior label ${revision}`,
reasoning_label_step_id: `prior-step-${revision}`,
reasoning_label_revision: revision,
reasoning_label_status: 'complete',
}));
const resumed = createHarness(generate, {
minChars: 5,
updateChars: 4,
updateIntervalMs: 0,
maxPerRun: 8,
initialParts,
});
await resumed.start('reasoning-after-resume', initialParts.length);
await resumed.append('abcde', 'reasoning-after-resume');
await resumed.settle();
expect(generate).toHaveBeenCalledTimes(1);
expect(generate).toHaveBeenCalledWith(expect.objectContaining({ revision: 5 }));
});
it('starts a fresh call budget when retained edit content is historical', async () => {
const generate = jest.fn(async () => ({ label: 'Tracing the edited direction' }));
const harness = createHarness(generate, {
minChars: 5,
updateChars: 4,
updateIntervalMs: 0,
maxPerRun: 1,
initialAttempts: 0,
initialParts: [
{
type: ContentTypes.THINK,
think: 'historical reasoning',
reasoning_label_step_id: 'old-step',
reasoning_label_attempts: 8,
reasoning_label_submitted_chars: 20,
},
],
});
await harness.start('reasoning-new', 1);
await harness.append('abcde', 'reasoning-new');
await harness.settle();
expect(generate).toHaveBeenCalledTimes(1);
expect(generate).toHaveBeenCalledWith(expect.objectContaining({ revision: 1 }));
});
it('keeps the run-cumulative cap when a new step reuses a THINK slot before resume', async () => {
const generate = jest.fn(async () => ({}));
const firstSegment = createHarness(generate, {
minChars: 5,
updateChars: 4,
updateIntervalMs: 0,
maxPerRun: 2,
});
await firstSegment.start('reasoning-1', 0);
await firstSegment.append('12345', 'reasoning-1');
await firstSegment.settle();
await firstSegment.append('6789', 'reasoning-1');
await firstSegment.settle();
await firstSegment.start('reasoning-2', 0);
expect(firstSegment.parts[0]).toMatchObject({
reasoning_label_step_id: 'reasoning-2',
reasoning_label_attempts: 2,
});
expect(generate).toHaveBeenCalledTimes(2);
expect(firstSegment.attemptEvents.map((event) => event.attempts)).toEqual([1, 2]);
const resumedGenerate = jest.fn(async () => ({ label: 'Should not run' }));
const resumed = createHarness(resumedGenerate, {
minChars: 5,
updateChars: 4,
updateIntervalMs: 0,
maxPerRun: 2,
initialParts: firstSegment.parts.map((part) => ({ ...part })),
});
await resumed.start('reasoning-3', 1);
await resumed.append('vwxyz', 'reasoning-3');
await resumed.close('reasoning-3');
await resumed.settle();
expect(resumedGenerate).not.toHaveBeenCalled();
});
it('resumes the character diff from the last attempted evidence length', async () => {
const generate = jest.fn(async () => ({ label: 'Tracing the resumed direction' }));
const harness = createHarness(generate, {
minChars: 5,
updateChars: 4,
updateIntervalMs: 0,
preservePartOnStart: true,
initialParts: [
{
type: ContentTypes.THINK,
think: '1234567',
reasoning_label_step_id: 'reasoning-1',
reasoning_label_attempts: 1,
reasoning_label_submitted_chars: 5,
},
],
});
await harness.start('reasoning-1', 0);
expect(generate).not.toHaveBeenCalled();
await harness.append('89', 'reasoning-1');
await harness.settle();
expect(generate).toHaveBeenCalledTimes(1);
expect(generate).toHaveBeenCalledWith(
expect.objectContaining({ visibleReasoning: '123456789', revision: 2 }),
);
});
it('synthesizes only reasoning revisions that changed during a resume gap', () => {
const snapshot = [
{
type: ContentTypes.THINK,
think: 'reasoning',
reasoning_label: 'Inspecting the stream',
reasoning_label_step_id: 'step-1',
reasoning_label_revision: 1,
reasoning_label_status: 'streaming',
},
];
const fresh = [
{
...snapshot[0],
reasoning_label: 'Resolved the stream race',
reasoning_label_revision: 2,
reasoning_label_status: 'complete',
},
];
expect(
synthesizeReasoningLabelGapEvents(snapshot, fresh, {
conversationId: 'conversation-1',
responseMessageId: 'response-1',
}),
).toEqual([
{
event: 'on_reasoning_label',
data: {
index: 0,
stepId: 'step-1',
revision: 2,
label: 'Resolved the stream race',
status: 'complete',
conversationId: 'conversation-1',
responseMessageId: 'response-1',
},
},
]);
});
it('resets step ownership before synthesizing a reused slot revision', () => {
const snapshot = [
{
type: ContentTypes.THINK,
think: 'old reasoning',
reasoning_label: 'Inspecting the stream',
reasoning_label_step_id: 'step-old',
reasoning_label_revision: 1,
reasoning_label_status: 'streaming',
},
];
const fresh = [
{
...snapshot[0],
think: 'new reasoning',
reasoning_label_step_id: 'step-new',
},
];
expect(
synthesizeReasoningLabelGapEvents(snapshot, fresh, {
conversationId: 'conversation-1',
responseMessageId: 'response-1',
}),
).toEqual([
{
event: 'on_reasoning_label',
data: {
index: 0,
stepId: 'step-new',
reset: true,
previousStepId: 'step-old',
conversationId: 'conversation-1',
responseMessageId: 'response-1',
},
},
{
event: 'on_reasoning_label',
data: {
index: 0,
stepId: 'step-new',
revision: 1,
label: 'Inspecting the stream',
status: 'streaming',
conversationId: 'conversation-1',
responseMessageId: 'response-1',
},
},
]);
});
it('synthesizes a reset when a reused gap slot loses its prior label', () => {
const snapshot = [
{
type: ContentTypes.THINK,
think: 'old reasoning',
reasoning_label: 'Inspecting the stream',
reasoning_label_step_id: 'step-old',
reasoning_label_revision: 1,
reasoning_label_status: 'streaming',
},
];
const fresh = [
{
type: ContentTypes.THINK,
think: 'old reasoning plus a short new step',
reasoning_label_step_id: 'step-new',
reasoning_label_attempts: 1,
},
];
expect(
synthesizeReasoningLabelGapEvents(snapshot, fresh, {
conversationId: 'conversation-1',
responseMessageId: 'response-1',
}),
).toEqual([
{
event: 'on_reasoning_label',
data: {
index: 0,
stepId: 'step-new',
reset: true,
previousStepId: 'step-old',
attempts: 1,
conversationId: 'conversation-1',
responseMessageId: 'response-1',
},
},
]);
});
});

View file

@ -0,0 +1,676 @@
import { GraphEvents } from '@librechat/agents';
import { ContentTypes, StepTypes } from 'librechat-data-provider';
import type { EventHandler } from '@librechat/agents';
import type { LooseContentPart } from '~/agents/activityLabels/wiring';
export type ReasoningLabelStatus = 'streaming' | 'complete';
export interface GenerateReasoningLabelPayload {
visibleReasoning: string;
reasoningStepId: string;
revision: number;
status: ReasoningLabelStatus;
previousLabel?: string;
agentId?: string;
prompt?: string;
charLimit: number;
signal: AbortSignal;
}
export interface GeneratedReasoningLabel {
label?: string;
/** Bills this provider call; receives the least-normalized completion text
* available when metadata-based token counts are absent. */
collectUsage?: (completionText?: string) => void | Promise<void>;
}
export interface ReasoningLabelEvent {
index: number;
stepId: string;
revision: number;
label: string;
status: ReasoningLabelStatus;
}
export interface ReasoningLabelAttemptEvent {
index: number;
stepId: string;
attempts: number;
submittedChars: number;
}
export interface ReasoningLabelHostDeps {
minChars?: number;
updateChars?: number;
updateIntervalMs?: number;
maxPerRun?: number;
/** Explicit call-budget seed. Omit on HITL resume to derive it from content;
* pass zero for a new generation whose retained edit prefix is historical. */
initialAttempts?: number;
prompt?: string;
abortSignal?: AbortSignal;
getContentParts: () => Array<LooseContentPart | null | undefined>;
getStepIndex: (stepId: string) => number | undefined;
emitAttemptEvent: (event: ReasoningLabelAttemptEvent) => Promise<unknown>;
emitLabelEvent: (event: ReasoningLabelEvent) => Promise<unknown>;
trackPendingFill: (fillDone: Promise<void>) => void;
isClosed?: () => boolean;
generateLabel: (payload: GenerateReasoningLabelPayload) => Promise<GeneratedReasoningLabel>;
now?: () => number;
}
export interface ReasoningLabelWiring {
handlers: (
handlers: Record<string, EventHandler> | undefined,
) => Record<string, EventHandler> | undefined;
/** Closes every reasoning step and schedules any meaningful trailing revision. */
complete: () => void;
}
interface ReasoningLabelGapEvent {
event: string;
data: Record<string, unknown>;
}
interface ReasoningStepState {
stepId: string;
agentId?: string;
index?: number;
text: string;
totalChars: number;
label?: string;
labelStatus?: ReasoningLabelStatus;
revision: number;
attempts: number;
submittedChars: number;
lastSubmittedAt: number;
closed: boolean;
pendingFinal: boolean;
timer?: ReturnType<typeof setTimeout>;
inFlight?: Promise<void>;
completionTask?: Promise<void>;
}
const DEFAULT_MIN_CHARS = 500;
const DEFAULT_UPDATE_CHARS = 400;
const DEFAULT_UPDATE_INTERVAL_MS = 3_000;
const DEFAULT_MAX_PER_RUN = 8;
const REASONING_PROMPT_CHAR_LIMIT = 4_000;
const MAX_TRACKED_REASONING_CHARS = 8_000;
/** Terminal exception to the streaming gates: enough new evidence merits a
* completion rewrite; a smaller tail only upgrades the current status. */
const FINAL_UPDATE_CHAR_LIMIT = 120;
const OUTPUT_CHAR_LIMIT = 120;
const REQUEST_TIMEOUT_MS = 12_000;
function textValue(value: unknown): string {
if (typeof value === 'string') {
return value;
}
const nested = (value as { value?: unknown } | null | undefined)?.value;
return typeof nested === 'string' ? nested : '';
}
function deltaText(data: unknown): string {
const raw = (data as { delta?: { content?: unknown } } | null)?.delta?.content;
let parts: unknown[] = [];
if (Array.isArray(raw)) {
parts = raw;
} else if (raw != null) {
parts = [raw];
}
return parts
.filter((part) => (part as { type?: unknown } | null)?.type === ContentTypes.THINK)
.map((part) => textValue((part as { think?: unknown }).think))
.join('');
}
function normalizeLabel(value: string | undefined): string {
const firstLine = value?.split(/\r?\n/).find((line) => line.trim().length > 0) ?? '';
const normalized = firstLine
.replace(/\s+/g, ' ')
.replace(/[.!?]+$/, '')
.trim();
return normalized.length > OUTPUT_CHAR_LIMIT
? `${normalized.slice(0, OUTPUT_CHAR_LIMIT - 1)}`
: normalized;
}
function appendBoundedReasoning(current: string, delta: string): string {
const combined = `${current}${delta}`;
if (combined.length <= MAX_TRACKED_REASONING_CHARS) {
return combined;
}
const headChars = Math.floor(MAX_TRACKED_REASONING_CHARS / 4);
const tailChars = MAX_TRACKED_REASONING_CHARS - headChars;
return `${combined.slice(0, headChars)}\n…\n${combined.slice(-tailChars)}`;
}
function buildSignal(signal?: AbortSignal): AbortSignal {
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
return signal != null && typeof AbortSignal.any === 'function'
? AbortSignal.any([signal, timeout])
: timeout;
}
function getReasoningPart(
deps: ReasoningLabelHostDeps,
state: ReasoningStepState,
): LooseContentPart | null | undefined {
const index = deps.getStepIndex(state.stepId) ?? state.index;
if (index != null) {
state.index = index;
}
const part = index != null ? deps.getContentParts()[index] : undefined;
return part?.type === ContentTypes.THINK ? part : undefined;
}
/** Re-emits reasoning-title revisions or resets committed in the resume snapshot gap. */
export function synthesizeReasoningLabelGapEvents(
snapshotContent: ReadonlyArray<LooseContentPart | null | undefined>,
freshContent: ReadonlyArray<LooseContentPart | null | undefined>,
meta: { conversationId: string; responseMessageId?: string },
): ReasoningLabelGapEvent[] {
const events: ReasoningLabelGapEvent[] = [];
for (let i = 0; i < freshContent.length; i += 1) {
const part = freshContent[i];
if (part?.type !== ContentTypes.THINK) {
continue;
}
const snapshot = snapshotContent[i];
const snapshotStepId =
snapshot?.type === ContentTypes.THINK && typeof snapshot.reasoning_label_step_id === 'string'
? snapshot.reasoning_label_step_id
: undefined;
const freshStepId =
typeof part.reasoning_label_step_id === 'string' ? part.reasoning_label_step_id : undefined;
const freshHasLabel =
typeof part.reasoning_label === 'string' &&
part.reasoning_label.trim().length > 0 &&
typeof part.reasoning_label_revision === 'number' &&
freshStepId != null;
if (snapshotStepId != null && freshStepId != null && snapshotStepId !== freshStepId) {
events.push({
event: 'on_reasoning_label',
data: {
index: i,
stepId: freshStepId,
reset: true,
previousStepId: snapshotStepId,
...(typeof part.reasoning_label_attempts === 'number' && {
attempts: part.reasoning_label_attempts,
}),
conversationId: meta.conversationId,
...(meta.responseMessageId != null && { responseMessageId: meta.responseMessageId }),
},
});
}
if (!freshHasLabel) {
continue;
}
if (
snapshot?.type === ContentTypes.THINK &&
snapshot.reasoning_label_step_id === part.reasoning_label_step_id &&
snapshot.reasoning_label === part.reasoning_label &&
snapshot.reasoning_label_revision === part.reasoning_label_revision &&
snapshot.reasoning_label_status === part.reasoning_label_status
) {
continue;
}
events.push({
event: 'on_reasoning_label',
data: {
index: i,
stepId: part.reasoning_label_step_id,
revision: part.reasoning_label_revision,
label: part.reasoning_label,
status: part.reasoning_label_status === 'complete' ? 'complete' : 'streaming',
conversationId: meta.conversationId,
...(meta.responseMessageId != null && { responseMessageId: meta.responseMessageId }),
},
});
}
return events;
}
/**
* Adds a throttled, revision-safe title lifecycle to top-level reasoning run
* steps. Nested subagent-content envelopes have a separate persistence and UI
* lifecycle and are intentionally outside this wiring. The title lives on the
* THINK part itself, so updates never reserve or shift content indices.
*/
export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): ReasoningLabelWiring {
const minChars = deps.minChars ?? DEFAULT_MIN_CHARS;
const updateChars = deps.updateChars ?? DEFAULT_UPDATE_CHARS;
const updateIntervalMs = deps.updateIntervalMs ?? DEFAULT_UPDATE_INTERVAL_MS;
const maxPerRun = deps.maxPerRun ?? DEFAULT_MAX_PER_RUN;
const now = deps.now ?? Date.now;
const steps = new Map<string, ReasoningStepState>();
const activeStepByAgent = new Map<string, string>();
const initialParts = deps.getContentParts();
const durableAttempts = initialParts.reduce((highest, part) => {
if (part?.type !== ContentTypes.THINK || typeof part.reasoning_label_attempts !== 'number') {
return highest;
}
return Math.max(highest, part.reasoning_label_attempts);
}, 0);
const committedFallback = initialParts.reduce((highest, part) => {
if (
part?.type !== ContentTypes.THINK ||
typeof part.reasoning_label_revision !== 'number' ||
part.reasoning_label_revision <= 0
) {
return highest;
}
return Math.max(highest, part.reasoning_label_revision);
}, 0);
/** `reasoning_label_attempts` is a run-cumulative high-water mark, not a
* per-step counter. Taking the max lets a new reasoning step reuse the same
* THINK slot without erasing provider calls already spent before HITL. */
let generated = deps.initialAttempts ?? Math.max(durableAttempts, committedFallback);
const emitCommitted = async (
state: ReasoningStepState,
revision: number,
label: string,
status: ReasoningLabelStatus,
): Promise<boolean> => {
if (deps.isClosed?.() === true || deps.abortSignal?.aborted) {
return false;
}
const part = getReasoningPart(deps, state);
const index = state.index;
if (part == null || index == null || part.reasoning_label_step_id !== state.stepId) {
return false;
}
const currentRevision =
part.reasoning_label_step_id === state.stepId &&
typeof part.reasoning_label_revision === 'number'
? part.reasoning_label_revision
: 0;
if (revision < currentRevision) {
return false;
}
await deps.emitLabelEvent({ index, stepId: state.stepId, revision, label, status });
/** The durable emit can yield while another delta replaces the THINK
* object. Re-fetch the authoritative slot instead of mutating the
* pre-await reference, which may now be orphaned. */
const committedPart = getReasoningPart(deps, state);
if (
committedPart == null ||
state.index !== index ||
committedPart.reasoning_label_step_id !== state.stepId
) {
return false;
}
Object.assign(committedPart, {
reasoning_label: label,
reasoning_label_step_id: state.stepId,
reasoning_label_revision: revision,
reasoning_label_status: status,
});
state.label = label;
state.labelStatus = status;
state.revision = revision;
return true;
};
const markComplete = (state: ReasoningStepState): void => {
if (!state.label || state.completionTask != null) {
return;
}
const part = getReasoningPart(deps, state);
if (state.labelStatus === 'complete' || part?.reasoning_label_status === 'complete') {
return;
}
const task = emitCommitted(state, state.revision, state.label, 'complete')
.then(() => undefined)
.catch(() => undefined)
.finally(() => {
state.completionTask = undefined;
});
state.completionTask = task;
deps.trackPendingFill(task);
};
const shouldGenerate = (state: ReasoningStepState, final: boolean): boolean => {
const length = state.totalChars;
if (!state.label) {
if (length < minChars) {
return false;
}
if (state.submittedChars === 0) {
return true;
}
const changed = Math.max(0, length - state.submittedChars);
return changed >= (final ? Math.min(updateChars, FINAL_UPDATE_CHAR_LIMIT) : updateChars);
}
const changed = Math.max(0, length - state.submittedChars);
return changed >= (final ? Math.min(updateChars, FINAL_UPDATE_CHAR_LIMIT) : updateChars);
};
const clearTimer = (state: ReasoningStepState): void => {
if (state.timer != null) {
clearTimeout(state.timer);
state.timer = undefined;
}
};
const schedule = (state: ReasoningStepState, final = state.closed): void => {
if (deps.isClosed?.() === true || deps.abortSignal?.aborted) {
clearTimer(state);
return;
}
state.pendingFinal ||= final;
if (state.inFlight != null) {
return;
}
if (!shouldGenerate(state, state.pendingFinal)) {
clearTimer(state);
if (state.pendingFinal) {
markComplete(state);
}
return;
}
if (generated >= maxPerRun) {
clearTimer(state);
markComplete(state);
return;
}
const waitMs =
state.attempts > 0 ? Math.max(0, state.lastSubmittedAt + updateIntervalMs - now()) : 0;
if (!state.pendingFinal && waitMs > 0) {
if (state.timer == null) {
state.timer = setTimeout(() => {
state.timer = undefined;
schedule(state);
}, waitMs);
}
return;
}
clearTimer(state);
const status: ReasoningLabelStatus = state.pendingFinal ? 'complete' : 'streaming';
state.pendingFinal = false;
const visibleReasoning = state.text.trim();
state.submittedChars = state.totalChars;
state.lastSubmittedAt = now();
generated += 1;
state.attempts += 1;
const attempts = generated;
/** Provider-call sequence doubles as the visible revision. Failed or
* suppressed attempts may leave gaps, but a later call never reuses the
* same SDK/Langfuse trace identity. */
const revision = attempts;
const task = (async () => {
const part = getReasoningPart(deps, state);
const index = state.index;
if (part == null || index == null || part.reasoning_label_step_id !== state.stepId) {
return;
}
try {
await deps.emitAttemptEvent({
index,
stepId: state.stepId,
attempts,
submittedChars: state.submittedChars,
});
} catch {
return;
}
const reservedPart = getReasoningPart(deps, state);
if (
reservedPart == null ||
state.index !== index ||
reservedPart.reasoning_label_step_id !== state.stepId
) {
return;
}
Object.assign(reservedPart, {
reasoning_label_step_id: state.stepId,
reasoning_label_attempts: attempts,
reasoning_label_submitted_chars: state.submittedChars,
});
let generatedLabel: GeneratedReasoningLabel = {};
try {
generatedLabel = await deps.generateLabel({
visibleReasoning,
reasoningStepId: state.stepId,
revision,
status,
...(state.label != null && { previousLabel: state.label }),
...(state.agentId != null && { agentId: state.agentId }),
...(deps.prompt != null && { prompt: deps.prompt }),
charLimit: REASONING_PROMPT_CHAR_LIMIT,
signal: buildSignal(deps.abortSignal),
});
} catch {
generatedLabel = {};
}
const label = normalizeLabel(generatedLabel.label);
/** Provider usage is billable even when output normalization rejects
* the title or a later durable UI patch loses ownership. Start it in
* parallel so balance persistence never delays the visible revision. */
const usageTask = (async () => {
try {
await generatedLabel.collectUsage?.(generatedLabel.label || label || undefined);
} catch {
// Accounting failures must not suppress a valid visible title.
}
})();
/** Billing remains part of final settlement, but not the per-step
* generation lock. A slow balance write must not prevent a trailing
* terminal revision from being generated and durably shown. */
deps.trackPendingFill(usageTask);
if (!label) {
if (status === 'complete') {
markComplete(state);
}
return;
}
let committed = false;
try {
committed = await emitCommitted(state, revision, label, status);
} catch {
return;
}
if (!committed) {
return;
}
})().finally(() => {
state.inFlight = undefined;
if (state.closed || state.totalChars - state.submittedChars >= updateChars) {
schedule(state, state.closed);
}
});
state.inFlight = task;
deps.trackPendingFill(task);
};
const closeStep = (stepId: string): void => {
const state = steps.get(stepId);
if (state == null || state.closed) {
return;
}
state.closed = true;
clearTimer(state);
schedule(state, true);
};
const startStep = (data: unknown, metadata?: Record<string, unknown>): void => {
const step = data as {
id?: string;
agentId?: string;
stepDetails?: { type?: string; message_creation?: { content_type?: string } };
};
const hideSequentialOutputs = metadata?.hide_sequential_outputs === true;
const lastAgentId = metadata?.last_agent_id;
const graphNode = metadata?.langgraph_node;
const isLastAgent =
typeof lastAgentId === 'string' &&
typeof graphNode === 'string' &&
graphNode.endsWith(lastAgentId);
if (
(hideSequentialOutputs && !isLastAgent) ||
!step.id ||
step.stepDetails?.type !== StepTypes.MESSAGE_CREATION ||
step.stepDetails.message_creation?.content_type !== ContentTypes.THINK
) {
return;
}
const agentKey = step.agentId ?? 'root';
const previousStepId = activeStepByAgent.get(agentKey);
if (previousStepId != null && previousStepId !== step.id) {
closeStep(previousStepId);
}
activeStepByAgent.set(agentKey, step.id);
if (steps.has(step.id)) {
return;
}
const index = deps.getStepIndex(step.id);
const part = index != null ? deps.getContentParts()[index] : undefined;
const ownsExistingStep =
part?.type === ContentTypes.THINK && part.reasoning_label_step_id === step.id;
const existingLabel =
ownsExistingStep && typeof part.reasoning_label === 'string'
? part.reasoning_label
: undefined;
const existingText = ownsExistingStep ? textValue(part?.think) : '';
const persistedSubmittedChars =
ownsExistingStep && typeof part.reasoning_label_submitted_chars === 'number'
? Math.min(part.reasoning_label_submitted_chars, existingText.trim().length)
: undefined;
const hasSubmitted =
ownsExistingStep &&
(persistedSubmittedChars != null ||
(typeof part.reasoning_label_revision === 'number' && part.reasoning_label_revision > 0));
const revision =
ownsExistingStep && typeof part.reasoning_label_revision === 'number'
? part.reasoning_label_revision
: 0;
const state: ReasoningStepState = {
stepId: step.id,
...(step.agentId != null && { agentId: step.agentId }),
...(index != null && { index }),
text: appendBoundedReasoning('', existingText),
totalChars: existingText.trim().length,
...(existingLabel != null && { label: existingLabel }),
...(existingLabel != null && {
labelStatus: part?.reasoning_label_status === 'complete' ? 'complete' : 'streaming',
}),
revision,
attempts: hasSubmitted ? 1 : 0,
submittedChars: persistedSubmittedChars ?? (hasSubmitted ? existingText.trim().length : 0),
lastSubmittedAt: hasSubmitted ? now() : 0,
closed: false,
pendingFinal: false,
};
steps.set(step.id, state);
if (part?.type === ContentTypes.THINK) {
if (!ownsExistingStep) {
delete part.reasoning_label;
delete part.reasoning_label_revision;
delete part.reasoning_label_status;
delete part.reasoning_label_submitted_chars;
}
Object.assign(part, {
reasoning_label_step_id: step.id,
reasoning_label_attempts: generated,
});
}
schedule(state);
};
const appendDelta = (data: unknown): void => {
const event = data as { id?: string };
const text = deltaText(data);
if (!event.id || !text) {
return;
}
const state = steps.get(event.id);
if (state == null || state.closed) {
return;
}
const part = getReasoningPart(deps, state);
if (part != null) {
Object.assign(part, {
reasoning_label_step_id: state.stepId,
reasoning_label_attempts: generated,
...(state.attempts > 0 && {
reasoning_label_submitted_chars: state.submittedChars,
}),
...(state.label != null && state.revision > 0
? {
reasoning_label: state.label,
reasoning_label_revision: state.revision,
reasoning_label_status: state.labelStatus ?? 'streaming',
}
: {}),
});
}
state.text = appendBoundedReasoning(state.text, text);
state.totalChars += text.length;
schedule(state);
};
const wrapHandlers = (
handlers: Record<string, EventHandler> | undefined,
): Record<string, EventHandler> | undefined => {
if (handlers == null) {
return handlers;
}
const wrapped = { ...handlers };
const runStepHandler = handlers[GraphEvents.ON_RUN_STEP];
if (runStepHandler != null) {
wrapped[GraphEvents.ON_RUN_STEP] = {
handle: async (event, data, metadata, graph) => {
const result = await runStepHandler.handle(event, data, metadata, graph);
startStep(data, metadata);
return result;
},
};
}
const reasoningHandler = handlers[GraphEvents.ON_REASONING_DELTA];
if (reasoningHandler != null) {
wrapped[GraphEvents.ON_REASONING_DELTA] = {
handle: async (event, data, metadata, graph) => {
const result = await reasoningHandler.handle(event, data, metadata, graph);
appendDelta(data);
return result;
},
};
}
const messageHandler = handlers[GraphEvents.ON_MESSAGE_DELTA];
if (messageHandler != null) {
wrapped[GraphEvents.ON_MESSAGE_DELTA] = {
handle: async (event, data, metadata, graph) => {
const result = await messageHandler.handle(event, data, metadata, graph);
appendDelta(data);
return result;
},
};
}
const closedHandler = handlers[GraphEvents.ON_RUN_STEP_CLOSED];
if (closedHandler != null) {
wrapped[GraphEvents.ON_RUN_STEP_CLOSED] = {
handle: async (event, data, metadata, graph) => {
const result = await closedHandler.handle(event, data, metadata, graph);
const id = (data as { id?: string }).id;
if (id != null) {
closeStep(id);
}
return result;
},
};
}
return wrapped;
};
const complete = (): void => {
for (const state of steps.values()) {
closeStep(state.stepId);
}
};
return { handlers: wrapHandlers, complete };
}

View file

@ -64,6 +64,7 @@ import {
synthesizeAppliedSteerEvents,
} from './SteeringLifecycle';
import { synthesizeActivityLabelGapEvents } from '~/agents/activityLabels/wiring';
import { synthesizeReasoningLabelGapEvents } from '~/agents/reasoningLabels';
import { InMemoryEventTransport } from './implementations/InMemoryEventTransport';
import { InMemoryJobStore } from './implementations/InMemoryJobStore';
import { attachAskUserQuestionAnswers, normalizeResumeRunStepIndices } from '~/agents/hitl/resume';
@ -5001,10 +5002,20 @@ class GenerationJobManagerClass {
resumeState?.aggregatedContent?.some(
(part) => (part as { type?: string } | null)?.type === 'activity_label',
) === true;
const snapshotHasReasoningLabels =
resumeState?.aggregatedContent?.some(
(part) =>
(part as { type?: string; reasoning_label_revision?: unknown } | null)?.type ===
'think' &&
typeof (part as { reasoning_label_revision?: unknown }).reasoning_label_revision ===
'number',
) === true;
if (
resumeState != null &&
jobActive &&
(liveJob?.activityLabels === true || snapshotHasActivityLabels)
(liveJob?.activityLabels === true ||
snapshotHasActivityLabels ||
snapshotHasReasoningLabels)
) {
const labelContent = await readFreshContent();
if (options?.signal?.aborted || this.detachSubscriptionDuringShutdown(subscription)) {
@ -5021,6 +5032,16 @@ class GenerationJobManagerClass {
if (labelGapEvents.length > 0) {
pendingEvents.push(...(labelGapEvents as t.ServerSentEvent[]));
}
const reasoningGapEvents = synthesizeReasoningLabelGapEvents(
(resumeState.aggregatedContent ?? []) as Parameters<
typeof synthesizeReasoningLabelGapEvents
>[0],
labelContent as Parameters<typeof synthesizeReasoningLabelGapEvents>[1],
{ conversationId: streamId, responseMessageId: resumeState.responseMessageId },
);
if (reasoningGapEvents.length > 0) {
pendingEvents.push(...(reasoningGapEvents as t.ServerSentEvent[]));
}
}
}

View file

@ -3740,6 +3740,172 @@ describe('RedisJobStore Integration Tests', () => {
await store.destroy();
});
test('getContentParts patches the latest reasoning label onto its THINK part', async () => {
if (!ioredisClient) {
return;
}
const { RedisJobStore } = await import('../implementations/RedisJobStore');
const store = new RedisJobStore(ioredisClient);
await store.initialize();
const streamId = `reasoning-label-recon-${Date.now()}`;
await store.createJob(streamId, 'reasoning-label-user', streamId);
const chunks = [
{
event: 'on_run_step',
data: {
id: 'reasoning-step-1',
index: 0,
stepDetails: {
type: 'message_creation',
message_creation: { content_type: 'think' },
},
},
},
{
event: 'on_reasoning_delta',
data: {
id: 'reasoning-step-1',
delta: { content: { type: 'think', think: 'Inspecting the resume path.' } },
},
},
{
event: 'on_reasoning_label_attempt',
data: {
index: 0,
stepId: 'reasoning-step-1',
attempts: 1,
submittedChars: 27,
},
},
{
event: 'on_reasoning_label',
data: {
index: 0,
stepId: 'reasoning-step-1',
revision: 1,
label: 'Inspecting the resume path',
status: 'streaming',
},
},
{
event: 'on_reasoning_label_attempt',
data: {
index: 0,
stepId: 'reasoning-step-1',
attempts: 2,
submittedChars: 27,
},
},
{
event: 'on_reasoning_label',
data: {
index: 0,
stepId: 'reasoning-step-1',
revision: 2,
label: 'Resolved the resume race',
status: 'complete',
},
},
{
event: 'on_reasoning_delta',
data: {
id: 'reasoning-step-1',
delta: { content: { type: 'think', think: ' More detail followed.' } },
},
},
];
for (const chunk of chunks) {
await store.appendChunk(streamId, chunk);
}
const result = await store.getContentParts(streamId);
expect(result?.content).toHaveLength(1);
expect(result?.content[0]).toMatchObject({
type: 'think',
think: 'Inspecting the resume path. More detail followed.',
reasoning_label: 'Resolved the resume race',
reasoning_label_step_id: 'reasoning-step-1',
reasoning_label_attempts: 2,
reasoning_label_submitted_chars: 27,
reasoning_label_revision: 2,
reasoning_label_status: 'complete',
});
await store.destroy();
});
test('getContentParts preserves the attempt cap when a sparse THINK slot is reused', async () => {
if (!ioredisClient) {
return;
}
const { RedisJobStore } = await import('../implementations/RedisJobStore');
const store = new RedisJobStore(ioredisClient);
await store.initialize();
const streamId = `reasoning-label-sparse-${Date.now()}`;
await store.createJob(streamId, 'reasoning-label-user', streamId);
await store.appendChunk(streamId, {
event: 'on_run_step',
data: {
id: 'reasoning-step-sparse',
index: 2,
stepDetails: {
type: 'message_creation',
message_creation: { content_type: 'think' },
},
},
});
await store.appendChunk(streamId, {
event: 'on_reasoning_delta',
data: {
id: 'reasoning-step-sparse',
delta: { content: { type: 'think', think: 'Inspecting a compacted stream.' } },
},
});
await store.appendChunk(streamId, {
event: 'on_reasoning_label_attempt',
data: {
index: 2,
stepId: 'reasoning-step-sparse',
attempts: 2,
submittedChars: 31,
},
});
await store.appendChunk(streamId, {
event: 'on_run_step',
data: {
id: 'reasoning-step-after-pause',
index: 2,
stepDetails: {
type: 'message_creation',
message_creation: { content_type: 'think' },
},
},
});
await store.appendChunk(streamId, {
event: 'on_reasoning_delta',
data: {
id: 'reasoning-step-after-pause',
delta: { content: { type: 'think', think: 'Continuing after the pause.' } },
},
});
const result = await store.getContentParts(streamId);
expect(result?.content).toHaveLength(1);
expect(result?.content[0]).toMatchObject({
type: 'think',
think: 'Inspecting a compacted stream.Continuing after the pause.',
reasoning_label_step_id: 'reasoning-step-after-pause',
reasoning_label_attempts: 2,
});
expect(result?.content[0]).not.toHaveProperty('reasoning_label_submitted_chars');
await store.destroy();
});
});
describe('Idempotency claims (#14339 duplicate-billing guard)', () => {

View file

@ -45,6 +45,28 @@ import { RecoveredSteerPayloadMismatchError } from '~/stream/SteerRecovery';
const CLIENT_REQUEST_ID_PATTERN = /^[A-Za-z0-9:_-]{1,128}$/;
type ReasoningLabelOverlay = {
stepId: string;
revision: number;
label: string;
status: 'streaming' | 'complete';
};
type ReasoningAttemptOverlay = {
stepId: string;
attempts: number;
submittedChars?: number;
};
type ReasoningContentPart = Agents.MessageContentComplex & {
reasoning_label?: string;
reasoning_label_step_id?: string;
reasoning_label_attempts?: number;
reasoning_label_submitted_chars?: number;
reasoning_label_revision?: number;
reasoning_label_status?: 'streaming' | 'complete';
};
function assertCreateIdempotencyArguments(
claimKey?: string,
claimToken?: string,
@ -2906,8 +2928,27 @@ export class RedisJobStore implements IJobStoreV2 {
}
const steers: Array<{ index: number; part: Agents.MessageContentComplex }> = [];
const labelsByIndex = new Map<number, Agents.MessageContentComplex>();
const reasoningStepsByIndex = new Map<number, string>();
const reasoningAttemptsByIndex = new Map<number, ReasoningAttemptOverlay>();
const reasoningLabelsByIndex = new Map<number, ReasoningLabelOverlay>();
let reasoningAttemptHighWater = 0;
for (const chunk of chunks) {
const event = chunk as { event?: string; data?: unknown };
if (event.event === 'on_run_step') {
const step = event.data as {
id?: string;
index?: number;
stepDetails?: { message_creation?: { content_type?: string } };
};
if (
typeof step.id === 'string' &&
typeof step.index === 'number' &&
step.stepDetails?.message_creation?.content_type === ContentTypes.THINK
) {
reasoningStepsByIndex.set(step.index, step.id);
}
continue;
}
if (event.event === 'on_steer_applied') {
const steerData = event.data as { index?: number; part?: Agents.MessageContentComplex };
if (typeof steerData.index === 'number' && steerData.part != null) {
@ -2920,9 +2961,62 @@ export class RedisJobStore implements IJobStoreV2 {
if (typeof labelData.index === 'number' && labelData.part != null) {
labelsByIndex.set(labelData.index, labelData.part);
}
continue;
}
if (event.event === 'on_reasoning_label_attempt') {
const attempt = event.data as {
index?: number;
stepId?: string;
attempts?: number;
submittedChars?: number;
};
if (
typeof attempt.index === 'number' &&
typeof attempt.stepId === 'string' &&
typeof attempt.attempts === 'number'
) {
reasoningAttemptsByIndex.set(attempt.index, {
stepId: attempt.stepId,
attempts: attempt.attempts,
...(typeof attempt.submittedChars === 'number' && {
submittedChars: attempt.submittedChars,
}),
});
reasoningAttemptHighWater = Math.max(reasoningAttemptHighWater, attempt.attempts);
}
continue;
}
if (event.event === 'on_reasoning_label') {
const labelData = event.data as {
index?: number;
stepId?: string;
revision?: number;
label?: string;
status?: 'streaming' | 'complete';
};
if (
typeof labelData.index === 'number' &&
typeof labelData.stepId === 'string' &&
typeof labelData.revision === 'number' &&
typeof labelData.label === 'string'
) {
reasoningLabelsByIndex.set(labelData.index, {
stepId: labelData.stepId,
revision: labelData.revision,
label: labelData.label,
status: labelData.status === 'complete' ? 'complete' : 'streaming',
});
}
continue;
}
}
if (steers.length === 0 && labelsByIndex.size === 0) {
if (
steers.length === 0 &&
labelsByIndex.size === 0 &&
reasoningStepsByIndex.size === 0 &&
reasoningAttemptHighWater === 0 &&
reasoningLabelsByIndex.size === 0
) {
return parts;
}
const inserts = [
@ -2934,6 +3028,46 @@ export class RedisJobStore implements IJobStoreV2 {
for (const insert of inserts) {
merged.splice(Math.min(insert.index, merged.length), 0, insert.part);
}
const reasoningIndices = new Set([
...reasoningStepsByIndex.keys(),
...reasoningAttemptsByIndex.keys(),
...reasoningLabelsByIndex.keys(),
]);
for (const index of reasoningIndices) {
const part = merged[index] as ReasoningContentPart | undefined;
if (part?.type !== ContentTypes.THINK) {
continue;
}
const attempt = reasoningAttemptsByIndex.get(index);
const label = reasoningLabelsByIndex.get(index);
const stepId = reasoningStepsByIndex.get(index) ?? attempt?.stepId ?? label?.stepId;
if (stepId == null) {
continue;
}
const updated: ReasoningContentPart = { ...part };
if (updated.reasoning_label_step_id != null && updated.reasoning_label_step_id !== stepId) {
delete updated.reasoning_label;
delete updated.reasoning_label_revision;
delete updated.reasoning_label_status;
delete updated.reasoning_label_submitted_chars;
}
updated.reasoning_label_step_id = stepId;
if (reasoningAttemptHighWater > 0) {
updated.reasoning_label_attempts = Math.max(
updated.reasoning_label_attempts ?? 0,
reasoningAttemptHighWater,
);
}
if (attempt?.stepId === stepId && attempt.submittedChars != null) {
updated.reasoning_label_submitted_chars = attempt.submittedChars;
}
if (label?.stepId === stepId) {
updated.reasoning_label = label.label;
updated.reasoning_label_revision = label.revision;
updated.reasoning_label_status = label.status;
}
merged[index] = updated;
}
return merged;
}
@ -3077,6 +3211,10 @@ export class RedisJobStore implements IJobStoreV2 {
// rather than by its own index — otherwise a run containing a steer or
// HITL resume stamps the status onto the wrong slot.
const replayedStepIndices = new Map<string, number>();
const reasoningStepsByIndex = new Map<number, string>();
const reasoningAttemptsByIndex = new Map<number, ReasoningAttemptOverlay>();
const reasoningLabelsByIndex = new Map<number, ReasoningLabelOverlay>();
let reasoningAttemptHighWater = 0;
// Valid event types for content aggregation
const validEvents = new Set([
@ -3117,6 +3255,61 @@ export class RedisJobStore implements IJobStoreV2 {
continue;
}
// Attempt reservations are durable but intentionally non-rendering.
// Overlay their run-cumulative high-water mark after replay so later
// deltas cannot erase the cost cap before a HITL resume.
if (event.event === 'on_reasoning_label_attempt') {
const attempt = event.data as {
index?: number;
stepId?: string;
attempts?: number;
submittedChars?: number;
};
if (
typeof attempt.index === 'number' &&
typeof attempt.stepId === 'string' &&
typeof attempt.attempts === 'number'
) {
reasoningAttemptsByIndex.set(attempt.index, {
stepId: attempt.stepId,
attempts: attempt.attempts,
...(typeof attempt.submittedChars === 'number' && {
submittedChars: attempt.submittedChars,
}),
});
reasoningAttemptHighWater = Math.max(reasoningAttemptHighWater, attempt.attempts);
}
continue;
}
// Reasoning labels patch an existing THINK part and never shift indices.
// Retain the latest update until replay is complete: a later reasoning
// delta rebuilds the THINK object and would otherwise erase metadata
// from an earlier label event.
if (event.event === 'on_reasoning_label') {
const labelData = event.data as {
index?: number;
stepId?: string;
revision?: number;
label?: string;
status?: 'streaming' | 'complete';
};
if (
typeof labelData.index === 'number' &&
typeof labelData.stepId === 'string' &&
typeof labelData.revision === 'number' &&
typeof labelData.label === 'string'
) {
reasoningLabelsByIndex.set(labelData.index, {
stepId: labelData.stepId,
revision: labelData.revision,
label: labelData.label,
status: labelData.status === 'complete' ? 'complete' : 'streaming',
});
}
continue;
}
// Step closures are host-authored like steers and labels: the SDK
// aggregator has no notion of the event, so the terminal status is
// stamped onto the part the replayed steps already rebuilt. Resolved by
@ -3147,9 +3340,16 @@ export class RedisJobStore implements IJobStoreV2 {
}
if (event.event === 'on_run_step') {
const step = event.data as { id?: string; index?: number };
const step = event.data as {
id?: string;
index?: number;
stepDetails?: { message_creation?: { content_type?: string } };
};
if (step.id != null && typeof step.index === 'number') {
replayedStepIndices.set(step.id, step.index);
if (step.stepDetails?.message_creation?.content_type === ContentTypes.THINK) {
reasoningStepsByIndex.set(step.index, step.id);
}
}
}
@ -3158,6 +3358,45 @@ export class RedisJobStore implements IJobStoreV2 {
aggregateContent({ event: event.event as any, data: event.data as any });
}
const reasoningIndices = new Set([
...reasoningStepsByIndex.keys(),
...reasoningAttemptsByIndex.keys(),
...reasoningLabelsByIndex.keys(),
]);
for (const index of reasoningIndices) {
const part = contentParts[index] as ReasoningContentPart | undefined;
if (part?.type !== ContentTypes.THINK) {
continue;
}
const attempt = reasoningAttemptsByIndex.get(index);
const label = reasoningLabelsByIndex.get(index);
const stepId = reasoningStepsByIndex.get(index) ?? attempt?.stepId ?? label?.stepId;
if (stepId == null) {
continue;
}
if (part.reasoning_label_step_id != null && part.reasoning_label_step_id !== stepId) {
delete part.reasoning_label;
delete part.reasoning_label_revision;
delete part.reasoning_label_status;
delete part.reasoning_label_submitted_chars;
}
part.reasoning_label_step_id = stepId;
if (reasoningAttemptHighWater > 0) {
part.reasoning_label_attempts = Math.max(
part.reasoning_label_attempts ?? 0,
reasoningAttemptHighWater,
);
}
if (attempt?.stepId === stepId && attempt.submittedChars != null) {
part.reasoning_label_submitted_chars = attempt.submittedChars;
}
if (label?.stepId === stepId) {
part.reasoning_label = label.label;
part.reasoning_label_revision = label.revision;
part.reasoning_label_status = label.status;
}
}
// Filter out undefined entries
const filtered: Agents.MessageContentComplex[] = [];
for (const part of contentParts) {

View file

@ -693,6 +693,22 @@ export const baseEndpointSchema = z.object({
activityPhasePrompt: z.string().optional(),
/** Cost cap: maximum phase summaries generated per run. Default 5. */
activityPhaseMaxPerRun: z.number().int().positive().optional(),
/** Generates a live orientation label for sufficiently long top-level response reasoning. */
reasoningLabel: z.boolean().optional(),
/** Model used for reasoning labels. Defaults to activityModel, titleModel, then run model. */
reasoningLabelModel: z.string().optional(),
/** Endpoint receiving the bounded visible-reasoning snapshot. Defaults to activityEndpoint. */
reasoningLabelEndpoint: z.string().optional(),
/** Overrides the dedicated reasoning-label system prompt. */
reasoningLabelPrompt: z.string().optional(),
/** Characters required before the first reasoning label. Default 500. */
reasoningLabelMinChars: z.number().int().positive().optional(),
/** New characters required between streaming revisions. Default 400. */
reasoningLabelUpdateChars: z.number().int().positive().optional(),
/** Minimum milliseconds between streaming revisions. Default 3000. */
reasoningLabelUpdateIntervalMs: z.number().int().nonnegative().optional(),
/** Cost cap: maximum reasoning-label provider calls attempted per run. Default 8. */
reasoningLabelMaxPerRun: z.number().int().positive().optional(),
/** Maximum characters allowed in a single tool result before truncation. */
maxToolResultChars: z.number().positive().optional(),
});
@ -1143,6 +1159,14 @@ export const azureEndpointSchema = z
activityPhaseEndpoint: true,
activityPhasePrompt: true,
activityPhaseMaxPerRun: true,
reasoningLabel: true,
reasoningLabelModel: true,
reasoningLabelEndpoint: true,
reasoningLabelPrompt: true,
reasoningLabelMinChars: true,
reasoningLabelUpdateChars: true,
reasoningLabelUpdateIntervalMs: true,
reasoningLabelMaxPerRun: true,
})
.partial(),
);

View file

@ -1,5 +1,26 @@
import type { TMessageContentParts } from './types/assistants';
import type { TFile } from './types/files';
import type { TMessage } from './types';
import { ContentTypes } from './types/runs';
/** A generated reasoning title describes the text as it existed at generation time.
* Any manual edit or merge into a different reasoning step invalidates the entire
* title revision domain while preserving unrelated content metadata. */
export function stripReasoningLabelMetadata(part: TMessageContentParts): TMessageContentParts {
if (part.type !== ContentTypes.THINK) {
return part;
}
const {
reasoning_label: _label,
reasoning_label_step_id: _stepId,
reasoning_label_attempts: _attempts,
reasoning_label_submitted_chars: _submittedChars,
reasoning_label_revision: _revision,
reasoning_label_status: _status,
...unlabeledPart
} = part;
return unlabeledPart;
}
export type ParentMessage = TMessage & { children: TMessage[]; depth: number };
/**

View file

@ -663,7 +663,22 @@ export type TMessageContentParts =
text?: string | TextData;
error?: string;
} & ContentMetadata)
| ({ type: ContentTypes.THINK; think?: string | TextData } & ContentMetadata)
| ({
type: ContentTypes.THINK;
think?: string | TextData;
/** Generated orientation for this user-visible reasoning step. */
reasoning_label?: string;
/** Stable SDK run-step identity used to correlate live revisions. */
reasoning_label_step_id?: string;
/** Durable provider-call count used to enforce the per-run cost cap across resumes. */
reasoning_label_attempts?: number;
/** Visible reasoning length included in this step's latest provider call. */
reasoning_label_submitted_chars?: number;
/** Monotonic provider-call revision; gaps are allowed after unsuccessful attempts. */
reasoning_label_revision?: number;
/** Whether the reasoning step can still produce a newer label. */
reasoning_label_status?: 'streaming' | 'complete';
} & ContentMetadata)
| (SteerContentPart & ContentMetadata)
| ({
type: ContentTypes.TEXT;

View file

@ -46,6 +46,17 @@ describe('promptTokensFromUsage', () => {
expect(promptTokensFromUsage(event)).toBe(120);
});
it('accepts the reasoning-label usage bucket emitted on the wire', () => {
const event: TTokenUsageEvent = {
input_tokens: 85,
output_tokens: 7,
usage_type: 'reasoning-label',
runId: 'msg-1:1700000000000',
seq: -2,
};
expect(promptTokensFromUsage(event)).toBe(85);
});
it('uses the magnitude heuristic when the provider is absent (cache ≤ input ⇒ included)', () => {
/** OpenAI-compatible/custom payload with no provider: cache already folded
* into input_tokens, so it must NOT be re-added. */

View file

@ -93,6 +93,49 @@ export enum ActivityLabelEvents {
ON_ACTIVITY_LABEL = 'on_activity_label',
}
/** Live title updates for an existing reasoning content part. */
export enum ReasoningLabelEvents {
ON_REASONING_LABEL = 'on_reasoning_label',
/** Internal durable budget reservation; clients intentionally do not render it. */
ON_REASONING_LABEL_ATTEMPT = 'on_reasoning_label_attempt',
}
type TReasoningLabelEventBase = {
/** Completion-local content index of the reasoning part being updated. */
index: number;
stepId: string;
responseMessageId?: string;
conversationId?: string;
};
/** Payload of the `on_reasoning_label` SSE event. */
export type TReasoningLabelEvent = TReasoningLabelEventBase &
(
| {
/** Clears a snapshot title when its THINK slot changed during the resume gap. */
reset: true;
/** Step identity observed in the snapshot and exclusively eligible for this reset. */
previousStepId: string;
/** Latest run-global call-budget high-water, when present on fresh content. */
attempts?: number;
}
| {
reset?: false;
/** Run-unique provider-call revision; may contain gaps after unsuccessful attempts. */
revision: number;
label: string;
status: 'streaming' | 'complete';
}
);
/** Durable run-cumulative call-budget reservation, attributed to one reasoning step. */
export type TReasoningLabelAttemptEvent = {
index: number;
stepId: string;
attempts: number;
submittedChars: number;
};
/** Payload of the `on_activity_label` SSE event. */
export type TActivityLabelEvent = {
/** Absolute content index the label part occupies. */
@ -238,8 +281,15 @@ export type TTokenUsageEvent = {
/** Non-primary buckets fold into session cost/totals but not the live
* context gauge: hidden sequential-agent calls (`sequential`), summary
* passes (`summarization`), isolated subagent runs (`subagent`), and
* fast-model activity headers (`activity-label`, `activity-phase`) */
usage_type?: 'summarization' | 'subagent' | 'sequential' | 'activity-label' | 'activity-phase';
* fast-model activity headers (`activity-label`, `activity-phase`), and
* live reasoning titles (`reasoning-label`) */
usage_type?:
| 'summarization'
| 'subagent'
| 'sequential'
| 'activity-label'
| 'activity-phase'
| 'reasoning-label';
runId?: string;
/** Per-run emission sequence; keeps identical payloads from distinct model calls unique */
seq?: number;