mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🎯 fix: Make the Activity Header Say Something the Cards Cannot
The header read "ran 1 command" next to a card already labeled "Code" — it restated the UI beneath it instead of adding to it. Two causes, both about content rather than timing: - A deterministic tool-type tally was the primary display and also fed the prompt, so the best case was a tally and the worst case was a tally dressed as prose. Removed from the metadata, the prompt, the part type, and the client. - The instruction only ever reached the fallback path. The wiring passed a prompt only when was configured, so the preferred SDK path silently used the published package default. The wiring now always supplies one and the hook forwards it on both paths. The register is rewritten around what the cards cannot show: past-tense git-commit-subject, leading with the distinctive noun, outcome over attempt, tool names and counts and arguments explicitly forbidden. The batch entries are labeled as reference material so the model stops transcribing them. Claiming a slot no longer emits. The slot still reserves its index so streamed parts never collide, but with nothing to say there is nothing to render: until a description exists the block looks exactly as it does without the feature.
This commit is contained in:
parent
2a2260f2f2
commit
464639702e
14 changed files with 196 additions and 162 deletions
|
|
@ -443,7 +443,7 @@ class AgentClient extends BaseClient {
|
|||
* (thread_id) with its own tags — never as an orphan trace. Returns null
|
||||
* when the label could not be generated.
|
||||
*/
|
||||
async generateActivityLabelViaRun({ entries, context, traceSeed, signal, charLimit }) {
|
||||
async generateActivityLabelViaRun({ entries, context, traceSeed, signal, charLimit, prompt }) {
|
||||
if (typeof this.run?.generateActivityLabel !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -464,7 +464,12 @@ class AgentClient extends BaseClient {
|
|||
lastAssistantText: context.lastAssistantText,
|
||||
traceSeed,
|
||||
charLimit,
|
||||
...(this.activityLabelPrompt != null && { prompt: this.activityLabelPrompt }),
|
||||
/** The wiring always supplies one (the yaml `activityPrompt` when
|
||||
* set, else this repo's instruction). Falling through to the SDK's
|
||||
* built-in prompt would silently use a different register. */
|
||||
...((prompt ?? this.activityLabelPrompt) != null && {
|
||||
prompt: prompt ?? this.activityLabelPrompt,
|
||||
}),
|
||||
chainOptions: {
|
||||
signal,
|
||||
callbacks: [{ handleLLMEnd }],
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ const Part = memo(function Part({
|
|||
/** Orphan label (its block's parts were filtered/hidden): renders as a
|
||||
* standalone line. Labeled blocks normally render via ToolCallGroup,
|
||||
* which consumes the label part as the group header instead. */
|
||||
const display = getActivityLabelText(getActivityLabelPart(part), localize);
|
||||
const display = getActivityLabelText(getActivityLabelPart(part));
|
||||
if (!display) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,8 +86,8 @@ interface ToolCallGroupProps {
|
|||
groupAttachments?: TAttachment[];
|
||||
initialExpansionState?: ToolCallGroupExpansionState;
|
||||
onExpansionChange?: (state: ToolCallGroupExpansionState) => void;
|
||||
/** Activity-label part terminating this block; when present the header
|
||||
* shows the fast-model label (or its deterministic counts fallback). */
|
||||
/** Activity-label part terminating this block; when it carries generated
|
||||
* text the header shows that text instead of the default tool summary. */
|
||||
labelPart?: PartWithIndex;
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ export default function ToolCallGroup({
|
|||
[toolMetadata],
|
||||
);
|
||||
const activityLabel = getActivityLabelPart(labelPart?.part);
|
||||
const activityLabelText = getActivityLabelText(activityLabel, localize);
|
||||
const activityLabelText = getActivityLabelText(activityLabel);
|
||||
const activityFailed = activityLabel?.status === 'failed' || activityLabel?.status === 'partial';
|
||||
const toolNames = useMemo(() => toolMetadata.map((m) => m.name), [toolMetadata]);
|
||||
const iconToolNames = useMemo(() => toolMetadata.map((m) => m.iconName), [toolMetadata]);
|
||||
|
|
@ -264,8 +264,9 @@ export default function ToolCallGroup({
|
|||
}
|
||||
return localize('com_ui_used_n_tools', { 0: String(count) });
|
||||
};
|
||||
/** Fast-model activity label (or its counts fallback) wins over the
|
||||
* generic category verb when this block carries a label part. */
|
||||
/** The generated line wins over the generic category verb — but only once
|
||||
* it exists. An unfilled label part leaves the block rendering exactly as
|
||||
* it would without the feature. */
|
||||
const groupLabel = activityLabelText.length > 0 ? activityLabelText : resolveGroupLabel();
|
||||
/** Single category glyph for homogeneous groups (else StackedToolIcons). */
|
||||
const CategoryIcon = allSubagents ? Users : MessageCircleQuestion;
|
||||
|
|
|
|||
|
|
@ -59,9 +59,8 @@ jest.mock('~/utils', () => ({
|
|||
['execute_code', 'bash_tool', 'run_tools_with_code', 'run_tools_with_bash'].includes(name)
|
||||
? 'Code'
|
||||
: name,
|
||||
/** Real implementations: the group header renders the activity label (or
|
||||
* its deterministic counts fallback) through these, so stubbing them out
|
||||
* would hide the header logic under test. */
|
||||
/** Real implementations: the group header resolves its text through these,
|
||||
* so stubbing them out would hide the header logic under test. */
|
||||
getActivityLabelPart: jest.requireActual('~/utils/activityLabels').getActivityLabelPart,
|
||||
getActivityLabelText: jest.requireActual('~/utils/activityLabels').getActivityLabelText,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -202,10 +202,7 @@ export function formatMessageContent({
|
|||
}
|
||||
|
||||
if (content.type === ContentTypes.ACTIVITY_LABEL) {
|
||||
const text = getActivityLabelText(
|
||||
getActivityLabelPart(content as TMessageContentParts),
|
||||
localize,
|
||||
);
|
||||
const text = getActivityLabelText(getActivityLabelPart(content as TMessageContentParts));
|
||||
if (text.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1190,16 +1190,6 @@
|
|||
"com_ui_export_file_search": "File Search",
|
||||
"com_ui_export_image": "Image",
|
||||
"com_ui_export_retrieval": "Retrieval",
|
||||
"com_ui_activity_ran_one": "ran {{0}} command",
|
||||
"com_ui_activity_ran_other": "ran {{0}} commands",
|
||||
"com_ui_activity_read_one": "read {{0}} file",
|
||||
"com_ui_activity_read_other": "read {{0}} files",
|
||||
"com_ui_activity_searched_one": "searched {{0}} source",
|
||||
"com_ui_activity_searched_other": "searched {{0}} sources",
|
||||
"com_ui_activity_used_one": "used {{0}} tool",
|
||||
"com_ui_activity_used_other": "used {{0}} tools",
|
||||
"com_ui_activity_wrote_one": "wrote {{0}} file",
|
||||
"com_ui_activity_wrote_other": "wrote {{0}} files",
|
||||
"com_ui_export_activity_label": "Activity",
|
||||
"com_ui_export_steer": "You (steered)",
|
||||
"com_ui_export_summary": "Summary",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type { TActivityLabelEvent, TMessage } from 'librechat-data-provider';
|
||||
import type { LocalizeFunction } from '~/common';
|
||||
import { applyActivityLabelPart, buildActivityCountsPhrase } from '../activityLabels';
|
||||
|
||||
const localize: LocalizeFunction = ((key: string, options?: Record<string, string>) =>
|
||||
`${key}:${options?.[0] ?? ''}`) as LocalizeFunction;
|
||||
import { applyActivityLabelPart } from '../activityLabels';
|
||||
|
||||
const buildMessage = (content: TMessage['content']): TMessage =>
|
||||
({ messageId: 'm1', isCreatedByUser: false, content }) as TMessage;
|
||||
|
|
@ -53,13 +49,3 @@ describe('applyActivityLabelPart', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildActivityCountsPhrase', () => {
|
||||
it('localizes singular and plural segments', () => {
|
||||
const phrase = buildActivityCountsPhrase(
|
||||
{ searches: 2, reads: 1, writes: 0, commands: 0, other: 0 },
|
||||
localize,
|
||||
);
|
||||
expect(phrase).toBe('com_ui_activity_searched_other:2 · com_ui_activity_read_one:1');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type { TMessage, TActivityLabelEvent, TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { LocalizeFunction } from '~/common';
|
||||
|
||||
type ActivityLabelPart = Extract<TMessageContentParts, { type: ContentTypes.ACTIVITY_LABEL }>;
|
||||
|
||||
|
|
@ -11,52 +10,20 @@ export function getActivityLabelPart(
|
|||
return part?.type === ContentTypes.ACTIVITY_LABEL ? (part as ActivityLabelPart) : undefined;
|
||||
}
|
||||
|
||||
const COUNT_SEGMENTS: Array<{
|
||||
key: keyof NonNullable<ActivityLabelPart['counts']>;
|
||||
one: Parameters<LocalizeFunction>[0];
|
||||
other: Parameters<LocalizeFunction>[0];
|
||||
}> = [
|
||||
{ key: 'searches', one: 'com_ui_activity_searched_one', other: 'com_ui_activity_searched_other' },
|
||||
{ key: 'reads', one: 'com_ui_activity_read_one', other: 'com_ui_activity_read_other' },
|
||||
{ key: 'writes', one: 'com_ui_activity_wrote_one', other: 'com_ui_activity_wrote_other' },
|
||||
{ key: 'commands', one: 'com_ui_activity_ran_one', other: 'com_ui_activity_ran_other' },
|
||||
{ key: 'other', one: 'com_ui_activity_used_one', other: 'com_ui_activity_used_other' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Deterministic fallback header: renders instantly at batch end from tool-name
|
||||
* counts; the fast-model label replaces it when it arrives.
|
||||
* The generated description, or empty when none exists yet.
|
||||
*
|
||||
* There is deliberately NO fallback string. A templated stand-in
|
||||
* ("ran 1 command") only restates the tool card rendered directly beneath
|
||||
* it, and showing one changes the UI before anything worth reading exists.
|
||||
* Callers render nothing until this returns text.
|
||||
*/
|
||||
export function buildActivityCountsPhrase(
|
||||
counts: ActivityLabelPart['counts'] | undefined,
|
||||
localize: LocalizeFunction,
|
||||
): string {
|
||||
if (!counts) {
|
||||
return '';
|
||||
}
|
||||
const segments: string[] = [];
|
||||
for (const segment of COUNT_SEGMENTS) {
|
||||
const count = counts[segment.key];
|
||||
if (count > 0) {
|
||||
segments.push(localize(count === 1 ? segment.one : segment.other, { 0: String(count) }));
|
||||
}
|
||||
}
|
||||
return segments.join(' · ');
|
||||
}
|
||||
|
||||
/** Fast-model label when present, localized deterministic counts phrase otherwise. */
|
||||
export function getActivityLabelText(
|
||||
part: ActivityLabelPart | undefined,
|
||||
localize: LocalizeFunction,
|
||||
): string {
|
||||
export function getActivityLabelText(part: ActivityLabelPart | undefined): string {
|
||||
if (!part) {
|
||||
return '';
|
||||
}
|
||||
const label = part[ContentTypes.ACTIVITY_LABEL];
|
||||
if (typeof label === 'string' && label.length > 0) {
|
||||
return label;
|
||||
}
|
||||
return buildActivityCountsPhrase(part.counts, localize);
|
||||
return typeof label === 'string' ? label.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@ jest.mock('@librechat/agents', () => ({
|
|||
initializeModel: (...args: unknown[]) => mockInitializeModel(...(args as [])),
|
||||
}));
|
||||
|
||||
import { classifyBatch, createActivityLabelHook } from '../runtime';
|
||||
import {
|
||||
ACTIVITY_INSTRUCTION,
|
||||
buildPrompt,
|
||||
classifyBatch,
|
||||
createActivityLabelHook,
|
||||
} from '../runtime';
|
||||
import type { ActivityLabelBatchMeta, ActivityLabelSlot } from '../runtime';
|
||||
|
||||
/** Flushes the hook's detached generation chain. */
|
||||
|
|
@ -37,21 +42,13 @@ function batchInput(overrides: Partial<PostToolBatchHookInput> = {}): PostToolBa
|
|||
}
|
||||
|
||||
describe('classifyBatch', () => {
|
||||
it('classifies tool names deterministically and derives batch status', () => {
|
||||
it('collects the covered tool calls and derives batch status', () => {
|
||||
const meta = classifyBatch([
|
||||
{ toolName: 'web_search', toolInput: {}, toolUseId: 'a', status: 'success', toolOutput: '' },
|
||||
{ toolName: 'read_file', toolInput: {}, toolUseId: 'b', status: 'success', toolOutput: '' },
|
||||
{ toolName: 'edit_file', toolInput: {}, toolUseId: 'c', status: 'error', error: 'denied' },
|
||||
{
|
||||
toolName: 'search_mcp_github',
|
||||
toolInput: {},
|
||||
toolUseId: 'd',
|
||||
status: 'success',
|
||||
toolOutput: '',
|
||||
},
|
||||
]);
|
||||
expect(meta.counts).toEqual({ searches: 1, reads: 1, writes: 1, commands: 0, other: 1 });
|
||||
expect(meta.toolCallIds).toEqual(['a', 'b', 'c', 'd']);
|
||||
expect(meta.toolCallIds).toEqual(['a', 'b', 'c']);
|
||||
expect(meta.status).toBe('partial');
|
||||
});
|
||||
|
||||
|
|
@ -59,9 +56,62 @@ describe('classifyBatch', () => {
|
|||
const meta = classifyBatch([
|
||||
{ toolName: 'bash_tool', toolInput: {}, toolUseId: 'x', status: 'error', error: 'boom' },
|
||||
]);
|
||||
expect(meta.counts.commands).toBe(1);
|
||||
expect(meta.status).toBe('failed');
|
||||
});
|
||||
|
||||
/** A tool-type tally could only echo the cards under the header, so the
|
||||
* metadata deliberately has no place to put one. */
|
||||
it('carries no tool-type tally', () => {
|
||||
const meta = classifyBatch([
|
||||
{ toolName: 'bash_tool', toolInput: {}, toolUseId: 'x', status: 'success', toolOutput: '' },
|
||||
]);
|
||||
expect(Object.keys(meta).sort()).toEqual(['status', 'toolCallIds']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ACTIVITY_INSTRUCTION', () => {
|
||||
it('forbids restating what the tool cards already show', () => {
|
||||
expect(ACTIVITY_INSTRUCTION).toMatch(/never name the tools/i);
|
||||
expect(ACTIVITY_INSTRUCTION).toMatch(/never count them/i);
|
||||
expect(ACTIVITY_INSTRUCTION).toMatch(/never echo the arguments/i);
|
||||
});
|
||||
|
||||
it('asks for a past-tense outcome, not the attempt', () => {
|
||||
expect(ACTIVITY_INSTRUCTION).toMatch(/past tense/i);
|
||||
expect(ACTIVITY_INSTRUCTION).toMatch(/outcome, not the attempt/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPrompt', () => {
|
||||
it('carries intent and marks the calls as reference material', () => {
|
||||
const prompt = buildPrompt(
|
||||
[
|
||||
{
|
||||
toolName: 'bash_tool',
|
||||
toolInput: { command: 'ls /mnt/data' },
|
||||
toolUseId: 'a',
|
||||
status: 'success',
|
||||
toolOutput: 'empty',
|
||||
},
|
||||
],
|
||||
600,
|
||||
{
|
||||
lastAssistantText: 'Let me check what is actually in /mnt/data',
|
||||
thinkingExcerpts: ['The filesystem seems to reset between calls'],
|
||||
},
|
||||
);
|
||||
expect(prompt).toContain('Let me check what is actually in /mnt/data');
|
||||
expect(prompt).toContain('The filesystem seems to reset between calls');
|
||||
expect(prompt).toContain('do not restate these');
|
||||
/** Outputs are the whole reason this runs after the batch. */
|
||||
expect(prompt).toContain('empty');
|
||||
});
|
||||
|
||||
it('uses the caller instruction verbatim when one is supplied', () => {
|
||||
const prompt = buildPrompt([], 600, undefined, 'CUSTOM RULE');
|
||||
expect(prompt.startsWith('CUSTOM RULE')).toBe(true);
|
||||
expect(prompt).not.toContain('git commit subject');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createActivityLabelHook', () => {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
stripActivityLabelParts,
|
||||
synthesizeActivityLabelGapEvents,
|
||||
} from '../wiring';
|
||||
import { ACTIVITY_INSTRUCTION } from '../runtime';
|
||||
|
||||
async function flushDetached(): Promise<void> {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
|
|
@ -173,17 +174,58 @@ describe('createActivityLabelWiring close gate', () => {
|
|||
|
||||
await hook(batchInput(), new AbortController().signal);
|
||||
await flushDetached();
|
||||
/** Claim-time placeholder emitted; label still in flight. */
|
||||
expect(emitLabelEvent).toHaveBeenCalledTimes(1);
|
||||
/** The slot is reserved but silent: claiming one emits nothing, so the
|
||||
* UI is untouched until a real label exists. */
|
||||
expect(emitLabelEvent).not.toHaveBeenCalled();
|
||||
|
||||
/** Settle timed out: the scope closes, then the straggler resolves. */
|
||||
closed = true;
|
||||
releaseLabel('Late label that must not land');
|
||||
await flushDetached();
|
||||
|
||||
expect(emitLabelEvent).toHaveBeenCalledTimes(1);
|
||||
expect(emitLabelEvent).not.toHaveBeenCalled();
|
||||
const labelPart = parts[1] as LooseContentPart;
|
||||
expect(labelPart.activity_label).toBe('');
|
||||
expect(labelPart.pending).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createActivityLabelWiring instruction', () => {
|
||||
const runWith = async (prompt?: string) => {
|
||||
const parts: Array<LooseContentPart | null | undefined> = [
|
||||
{ type: 'tool_call', tool_call: { id: 'tool-1' } },
|
||||
];
|
||||
const generateLabel = jest.fn(async () => 'Confirmed the sandbox resets');
|
||||
const { hook } = createActivityLabelWiring({
|
||||
getContentParts: () => parts,
|
||||
bumpIndexOffset: jest.fn(),
|
||||
emitLabelEvent: jest.fn(async () => undefined),
|
||||
trackPendingFill: jest.fn(),
|
||||
resolveLLM: jest.fn(async () => ({
|
||||
provider: Providers.OPENAI,
|
||||
clientOptions: { model: 'm' },
|
||||
})),
|
||||
generateLabel,
|
||||
...(prompt != null && { prompt }),
|
||||
});
|
||||
await hook(batchInput(), new AbortController().signal);
|
||||
await flushDetached();
|
||||
return generateLabel;
|
||||
};
|
||||
|
||||
/** Without this the SDK path silently uses the published package's own
|
||||
* generic prompt, and only the fallback path gets this repo's register. */
|
||||
it('always forwards an instruction to the SDK path', async () => {
|
||||
const generateLabel = await runWith();
|
||||
expect(generateLabel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prompt: ACTIVITY_INSTRUCTION }),
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers the configured activityPrompt when one is set', async () => {
|
||||
const generateLabel = await runWith('House style, please');
|
||||
expect(generateLabel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prompt: 'House style, please' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
export { classifyBatch, createActivityLabelHook } from './runtime';
|
||||
export {
|
||||
ACTIVITY_INSTRUCTION,
|
||||
buildPrompt,
|
||||
classifyBatch,
|
||||
createActivityLabelHook,
|
||||
} from './runtime';
|
||||
export type {
|
||||
ToolBatchCounts,
|
||||
ActivityLabelBatchMeta,
|
||||
ActivityLabelBlockContext,
|
||||
ActivityLabelHookOptions,
|
||||
|
|
|
|||
|
|
@ -17,19 +17,16 @@ export interface ActivityLabelLLM {
|
|||
endpointTokenConfig?: unknown;
|
||||
}
|
||||
|
||||
/** Deterministic classification of a batch — computed from tool names, no LLM. */
|
||||
export interface ToolBatchCounts {
|
||||
searches: number;
|
||||
reads: number;
|
||||
writes: number;
|
||||
commands: number;
|
||||
other: number;
|
||||
}
|
||||
|
||||
/** Batch metadata handed to the host at slot-claim time (all deterministic). */
|
||||
/**
|
||||
* Batch metadata handed to the host at slot-claim time (all deterministic).
|
||||
*
|
||||
* Deliberately carries no tool-type tally. A tally can only restate the tool
|
||||
* cards rendered directly beneath the header ("ran 1 command"), so it has no
|
||||
* place in either the prompt or the UI; the header earns its row solely by
|
||||
* saying something the cards cannot.
|
||||
*/
|
||||
export interface ActivityLabelBatchMeta {
|
||||
toolCallIds: string[];
|
||||
counts: ToolBatchCounts;
|
||||
/** ok = all succeeded, failed = all failed, partial = mixed. */
|
||||
status: 'ok' | 'partial' | 'failed';
|
||||
/** Owning agent in multi-agent graphs — lets the host stamp the part for lane grouping. */
|
||||
|
|
@ -66,6 +63,12 @@ export interface GenerateLabelPayload {
|
|||
signal: AbortSignal;
|
||||
/** Effective per-entry truncation, forwarded so host and SDK prompts agree. */
|
||||
charLimit: number;
|
||||
/**
|
||||
* Instruction for the label model. Always sent: left unset, the SDK falls
|
||||
* back to its own generic past-tense prompt and the register defined here
|
||||
* never reaches the preferred path.
|
||||
*/
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
/** Per-generation LLM callbacks for usage accounting on the fallback path. */
|
||||
|
|
@ -145,29 +148,14 @@ function stringifyUnknown(value: unknown): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Tool-name classification, mirroring Claude Code's streamlined-mode
|
||||
* taxonomy (searches/reads/writes/commands/other) with LibreChat tool names.
|
||||
* Prefix match covers MCP tool naming (`<tool>_mcp_<server>`).
|
||||
* Deterministic batch facts: which tool calls the label covers (for lane
|
||||
* stamping) and whether they succeeded (for failure tinting). No tool-type
|
||||
* tally — see {@link ActivityLabelBatchMeta}.
|
||||
*/
|
||||
const SEARCH_TOOLS = ['web_search', 'file_search', 'tool_search'];
|
||||
const READ_TOOLS = ['read_file', 'retrieval'];
|
||||
const WRITE_TOOLS = ['create_file', 'edit_file'];
|
||||
const COMMAND_TOOLS = ['execute_code', 'bash_tool'];
|
||||
|
||||
function categorizeToolName(toolName: string): keyof ToolBatchCounts {
|
||||
if (SEARCH_TOOLS.some((t) => toolName.startsWith(t))) return 'searches';
|
||||
if (READ_TOOLS.some((t) => toolName.startsWith(t))) return 'reads';
|
||||
if (WRITE_TOOLS.some((t) => toolName.startsWith(t))) return 'writes';
|
||||
if (COMMAND_TOOLS.some((t) => toolName.startsWith(t))) return 'commands';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function classifyBatch(entries: BatchEntry[]): ActivityLabelBatchMeta {
|
||||
const counts: ToolBatchCounts = { searches: 0, reads: 0, writes: 0, commands: 0, other: 0 };
|
||||
const toolCallIds: string[] = [];
|
||||
let failures = 0;
|
||||
for (const entry of entries) {
|
||||
counts[categorizeToolName(entry.toolName)] += 1;
|
||||
toolCallIds.push(entry.toolUseId);
|
||||
if (entry.status === 'error') {
|
||||
failures += 1;
|
||||
|
|
@ -179,23 +167,37 @@ export function classifyBatch(entries: BatchEntry[]): ActivityLabelBatchMeta {
|
|||
} else if (failures === entries.length) {
|
||||
status = 'failed';
|
||||
}
|
||||
return { toolCallIds, counts, status };
|
||||
return { toolCallIds, status };
|
||||
}
|
||||
|
||||
const INSTRUCTION = [
|
||||
'You write short labels for collapsed activity groups in a chat UI while an AI agent works.',
|
||||
'Describe what this block of reasoning and tool calls accomplished in 5 to 9 words.',
|
||||
'Past-tense verb first, distinctive nouns, outcomes not mechanics. If a call failed, say so plainly.',
|
||||
'Output only the label — no quotes, no markdown, no preamble, no trailing punctuation.',
|
||||
/**
|
||||
* The header sits directly above the tool cards it summarizes, so anything
|
||||
* the cards already display — tool names, how many ran, the arguments — is
|
||||
* noise when repeated. What the cards cannot show is the point of the batch
|
||||
* and how it came out, and that is the only thing worth a row of screen.
|
||||
*
|
||||
* Because this fires after the batch, the tool OUTPUTS are available: prefer
|
||||
* the answer the calls produced over a restatement of what was attempted.
|
||||
*/
|
||||
export const ACTIVITY_INSTRUCTION: string = [
|
||||
'You write the one-line header above a group of tool calls an AI agent just made.',
|
||||
'Write it like a git commit subject: past tense, verb first, leading with the most distinctive file, name, or finding.',
|
||||
'Say what the calls established or produced — the outcome, not the attempt. If they answered a question, the answer is the line.',
|
||||
'Never name the tools, never count them, never echo the arguments: the cards below the header already show all three.',
|
||||
'Write 4 to 9 words, sentence case, no trailing punctuation, no quotes or markdown.',
|
||||
'Good: "Confirmed /mnt/data resets between calls". "Traced the leak to formatAgentMessages". "Found 3 failing auth tests".',
|
||||
'Bad: "Ran 1 command". "Used bash_tool twice". "Executed ls /mnt/data". "Searched the codebase".',
|
||||
'If every call failed, say what failed and why, plainly.',
|
||||
'Output only the line.',
|
||||
].join(' ');
|
||||
|
||||
function buildPrompt(
|
||||
export function buildPrompt(
|
||||
entries: BatchEntry[],
|
||||
charLimit: number,
|
||||
context?: ActivityLabelBlockContext,
|
||||
instruction?: string,
|
||||
): string {
|
||||
const sections: string[] = [instruction ?? INSTRUCTION];
|
||||
const sections: string[] = [instruction ?? ACTIVITY_INSTRUCTION];
|
||||
if (context?.lastAssistantText) {
|
||||
sections.push(
|
||||
`Intent (assistant's last message): ${truncate(context.lastAssistantText, INPUT_CHAR_LIMIT)}`,
|
||||
|
|
@ -218,8 +220,10 @@ function buildPrompt(
|
|||
: truncate(stringifyUnknown(entry.toolOutput), charLimit);
|
||||
return `- ${entry.toolName}(${input}) → ${outcome}`;
|
||||
});
|
||||
sections.push(`Tool calls:\n${lines.join('\n')}`);
|
||||
sections.push('Label:');
|
||||
/** Flagged as reference material: without this the model tends to read the
|
||||
* list as the thing to summarize and hands back a transcription of it. */
|
||||
sections.push(`What it called, and what came back (do not restate these):\n${lines.join('\n')}`);
|
||||
sections.push('Header:');
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
|
|
@ -303,6 +307,7 @@ export function createActivityLabelHook(
|
|||
traceSeed: `${input.runId}-activity-${slot.index}`,
|
||||
signal,
|
||||
charLimit,
|
||||
...(opts.prompt != null && { prompt: opts.prompt }),
|
||||
});
|
||||
} else {
|
||||
const { provider, clientOptions } = await getLLM();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
ActivityLabelLLM,
|
||||
GenerateLabelPayload,
|
||||
} from './runtime';
|
||||
import { createActivityLabelHook } from './runtime';
|
||||
import { ACTIVITY_INSTRUCTION, createActivityLabelHook } from './runtime';
|
||||
|
||||
/** Structural view of a content part; hosts pass their live parts array. */
|
||||
export interface LooseContentPart {
|
||||
|
|
@ -194,10 +194,9 @@ export interface ActivityLabelHostDeps {
|
|||
|
||||
/**
|
||||
* Builds the run wiring for activity labels: slot claiming at each batch
|
||||
* boundary (steering's index-offset pattern), claim-time counts emit,
|
||||
* fill-time label emit ordered after the claim emit, groupId/agentId lane
|
||||
* stamping, and settle tracking. Implementation lives here (TS) so the JS
|
||||
* controller stays a thin wrapper.
|
||||
* boundary (steering's index-offset pattern), fill-time label emit,
|
||||
* groupId/agentId lane stamping, and settle tracking. Implementation lives
|
||||
* here (TS) so the JS controller stays a thin wrapper.
|
||||
*/
|
||||
export function createActivityLabelWiring(deps: ActivityLabelHostDeps): {
|
||||
hook: HookCallback<'PostToolBatch'>;
|
||||
|
|
@ -207,7 +206,10 @@ export function createActivityLabelWiring(deps: ActivityLabelHostDeps): {
|
|||
resolveLLM: deps.resolveLLM,
|
||||
...(deps.maxPerRun != null && { maxPerRun: deps.maxPerRun }),
|
||||
...(deps.charLimit != null && { charLimit: deps.charLimit }),
|
||||
...(deps.prompt != null && { prompt: deps.prompt }),
|
||||
/** Always send an instruction. With none, the SDK path falls back to
|
||||
* the published package's own generic prompt, so the register this
|
||||
* module defines would apply to the fallback path only. */
|
||||
prompt: deps.prompt ?? ACTIVITY_INSTRUCTION,
|
||||
/** Seed the cap from labels already on the response so a HITL resume
|
||||
* cannot mint a fresh quota after every approval. */
|
||||
initialGeneratedCount: deps
|
||||
|
|
@ -242,7 +244,6 @@ export function createActivityLabelWiring(deps: ActivityLabelHostDeps): {
|
|||
type: ContentTypes.ACTIVITY_LABEL,
|
||||
[ContentTypes.ACTIVITY_LABEL]: '',
|
||||
tool_call_ids: meta.toolCallIds,
|
||||
counts: meta.counts,
|
||||
status: meta.status,
|
||||
...(meta.executingAgentId != null && { agentId: meta.executingAgentId }),
|
||||
...(groupId != null && { groupId }),
|
||||
|
|
@ -250,12 +251,11 @@ export function createActivityLabelWiring(deps: ActivityLabelHostDeps): {
|
|||
};
|
||||
parts.push(part);
|
||||
deps.bumpIndexOffset();
|
||||
/** Claim-time emit: the counts phrase renders in the live UI
|
||||
* immediately at batch end. Fire-and-forget — claimSlot runs
|
||||
* inside the awaited hook, so it must not block on the emit —
|
||||
* but the promise is retained so fill() can order the resolved
|
||||
* label AFTER the placeholder in the durable chunk log. */
|
||||
const claimEmit = deps.emitLabelEvent(index, { ...part }).catch(() => undefined);
|
||||
/** No claim-time emit. The slot is reserved server-side so indices
|
||||
* stay stable, but an empty header has nothing to say — emitting it
|
||||
* would change the UI before the generation it announces exists.
|
||||
* Until `fill` lands, the client renders the batch exactly as it
|
||||
* does today. */
|
||||
let resolveFill: () => void = () => undefined;
|
||||
const fillDone = new Promise<void>((resolve) => {
|
||||
resolveFill = resolve;
|
||||
|
|
@ -276,10 +276,6 @@ export function createActivityLabelWiring(deps: ActivityLabelHostDeps): {
|
|||
return;
|
||||
}
|
||||
part[ContentTypes.ACTIVITY_LABEL] = text;
|
||||
await claimEmit;
|
||||
if (deps.isClosed?.() === true) {
|
||||
return;
|
||||
}
|
||||
await deps.emitLabelEvent(index, part);
|
||||
} finally {
|
||||
resolveFill();
|
||||
|
|
|
|||
|
|
@ -634,14 +634,6 @@ export type TMessageContentParts =
|
|||
type: ContentTypes.ACTIVITY_LABEL;
|
||||
activity_label?: string;
|
||||
tool_call_ids?: string[];
|
||||
/** Deterministic tool-name classification (no LLM): batch composition. */
|
||||
counts?: {
|
||||
searches: number;
|
||||
reads: number;
|
||||
writes: number;
|
||||
commands: number;
|
||||
other: number;
|
||||
};
|
||||
/** ok = all tools succeeded, failed = all failed, partial = mixed. */
|
||||
status?: 'ok' | 'partial' | 'failed';
|
||||
pending?: boolean;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue