mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 12:44:28 +00:00
🧠 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:
parent
3bd2358805
commit
7d850c308a
39 changed files with 3822 additions and 75 deletions
|
|
@ -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] || ''),
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
205
client/src/utils/__tests__/reasoningLabels.test.ts
Normal file
205
client/src/utils/__tests__/reasoningLabels.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
107
client/src/utils/reasoningLabels.ts
Normal file
107
client/src/utils/reasoningLabels.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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
10
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>) =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -25,14 +25,17 @@ export {
|
|||
mapCollectedMetadataToUsage,
|
||||
resolveActivityConfig,
|
||||
resolveActivityPhaseConfig,
|
||||
resolveReasoningLabelConfig,
|
||||
resolveActivityLabelModel,
|
||||
resolveActivityPhaseLabelModel,
|
||||
resolveReasoningLabelModel,
|
||||
settlePendingLabelFills,
|
||||
} from './host';
|
||||
export type {
|
||||
ActivityLabelAgent,
|
||||
ResolvedActivityConfig,
|
||||
ResolvedActivityPhaseConfig,
|
||||
ResolvedReasoningLabelConfig,
|
||||
ActivityLabelUsage,
|
||||
CollectedMetadataEntry,
|
||||
ResolveActivityLabelModelParams,
|
||||
|
|
|
|||
|
|
@ -44,4 +44,5 @@ export * from './hooks';
|
|||
export * from './steering';
|
||||
export * from './activityLabels';
|
||||
export * from './activityPhases';
|
||||
export * from './reasoningLabels';
|
||||
export * from './toolValidation';
|
||||
|
|
|
|||
69
packages/api/src/agents/reasoningLabels/host.spec.ts
Normal file
69
packages/api/src/agents/reasoningLabels/host.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
345
packages/api/src/agents/reasoningLabels/host.ts
Normal file
345
packages/api/src/agents/reasoningLabels/host.ts
Normal 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 ?? '',
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
23
packages/api/src/agents/reasoningLabels/index.ts
Normal file
23
packages/api/src/agents/reasoningLabels/index.ts
Normal 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';
|
||||
875
packages/api/src/agents/reasoningLabels/runtime.spec.ts
Normal file
875
packages/api/src/agents/reasoningLabels/runtime.spec.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
676
packages/api/src/agents/reasoningLabels/runtime.ts
Normal file
676
packages/api/src/agents/reasoningLabels/runtime.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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[]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue